From b6a5f105720cb89cdc39acb49a238994a4434f75 Mon Sep 17 00:00:00 2001 From: jrester <31157644+jrester@users.noreply.github.com> Date: Sat, 16 Sep 2023 15:03:57 +0200 Subject: [PATCH] feat #48: add meter details (#51) --- .envrc | 1 + .github/workflows/python-publish.yml | 2 +- .gitignore | 3 +- .pre-commit-config.yaml | 29 + CHANGELOG | 13 +- LICENSE | 4 +- README.md | 108 ++-- examples/example.py | 12 +- examples/influxDB.py | 47 -- pyproject.toml | 41 +- setup.cfg | 2 - setup.py | 24 - tesla_powerwall/__init__.py | 25 +- tesla_powerwall/api.py | 37 +- tesla_powerwall/const.py | 2 + tesla_powerwall/error.py | 10 +- tesla_powerwall/powerwall.py | 97 ++-- tesla_powerwall/responses.py | 499 ++++++++---------- tests/__init__.py | 0 tests/integration/test_powerwall.py | 45 +- tests/unit/__init__.py | 28 +- tests/unit/fixtures/grid_status.json | 5 +- .../unit/fixtures/islanding_mode_offgrid.json | 4 +- .../unit/fixtures/islanding_mode_ongrid.json | 4 +- tests/unit/fixtures/meter_site.json | 58 ++ tests/unit/fixtures/meter_solar.json | 53 ++ tests/unit/fixtures/meters_aggregates.json | 63 ++- tests/unit/fixtures/operation.json | 7 +- tests/unit/fixtures/powerwalls.json | 217 +++++++- tests/unit/fixtures/site_info.json | 23 +- tests/unit/fixtures/sitemaster.json | 7 +- tests/unit/fixtures/status.json | 11 +- tests/unit/fixtures/system_status.json | 122 ++++- tests/unit/test_api.py | 21 +- tests/unit/test_powerwall.py | 111 +++- tox.ini | 7 +- 36 files changed, 1202 insertions(+), 540 deletions(-) create mode 100644 .envrc create mode 100644 .pre-commit-config.yaml delete mode 100644 examples/influxDB.py delete mode 100644 setup.cfg delete mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/unit/fixtures/meter_site.json create mode 100644 tests/unit/fixtures/meter_solar.json diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..175de89 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +layout python diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index c29e1c5..fb4a130 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -6,7 +6,7 @@ on: - created - published - released - + jobs: deploy: diff --git a/.gitignore b/.gitignore index 75f19d1..5a330a9 100644 --- a/.gitignore +++ b/.gitignore @@ -123,4 +123,5 @@ dmypy.json .pyre/ .js -.vscode \ No newline at end of file +.vscode +.direnv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..fdeed13 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,29 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.0.285 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.5.1 + hooks: + - id: mypy + additional_dependencies: ["types-requests"] + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: pretty-format-json + args: [--autofix] + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + args: [--settings-path=pyproject.toml] + - repo: https://github.com/psf/black + rev: 23.7.0 + hooks: + - id: black + args: [--config=pyproject.toml] diff --git a/CHANGELOG b/CHANGELOG index ca6bbf6..1ef14a1 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,14 @@ # Changelog +## [0.4.0] + +- fix logout (https://github.com/jrester/tesla_powerwall/issues/50) +- add meter details for site and solar (https://github.com/jrester/tesla_powerwall/issues/48) +- rework response handling to now parse the responses directly instead of relying on lazy evaluation +- extend pre-commit hooks +- move to pyproject.toml and remove old setup.py + + ## [0.3.19] - add ability to take powerwall on/off grid. Thanks to @daniel-simpson (https://github.com/jrester/tesla_powerwall/pull/42) @@ -28,7 +37,7 @@ ## [0.3.14] -- revert changes from 0.3.11: +- revert changes from 0.3.11: - meters can now be accessed using the old, direct method (e.g. `meters.solar.instant_power`) - if a meter is not available a `MeterNotAvailableError` will be thrown - move from `distutils.version` to `packaging.version` @@ -45,4 +54,4 @@ Implement `system_status` endpoint (https://github.com/jrester/tesla_powerwall/i ## [0.3.11] -- meters of `MetersAggregates` can now only be accessed via `get_meter` (https://github.com/home-assistant/core/issues/56660) \ No newline at end of file +- meters of `MetersAggregates` can now only be accessed via `get_meter` (https://github.com/home-assistant/core/issues/56660) diff --git a/LICENSE b/LICENSE index 76ed278..53f8e84 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Jrester +Copyright (c) 2023 Jrester Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/README.md b/README.md index 0ee085f..03c3fd6 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,16 @@ ![PyPI - Downloads](https://img.shields.io/pypi/dm/tesla_powerwall?color=blue&style=for-the-badge) ![PyPI](https://img.shields.io/pypi/v/tesla_powerwall?style=for-the-badge) -Python Tesla Powerwall API for consuming a local endpoint. The API is by no means complete and mainly features methods which are considered to be of common use. If you feel like methods should be included you are welcome to open an Issue or create a Pull Request. - -> Note: This is not an official API provided by Tesla and not affilated in anyways with Tesla. - -Powerwall Software versions from 1.47.0 to 1.50.1 as well as 20.40 to 22.9.2 are tested, but others will probably work too. If you encounter an error regarding a change in the API of the Powerwall because your Powerwall has a different version than listed here please open an Issue to report this change so it can be fixed. +Python Tesla Powerwall API for consuming a local endpoint. +> Note: This is not an official API provided by Tesla and this project is not affilated with Tesla in any way. +Powerwall Software versions from 1.47.0 to 1.50.1 as well as 20.40 to 22.9.2 are tested, but others will probably work too. # Table of Contents - [Installation](#installation) +- [Limitations](#limitations) + - [Adjusting Backup Reserve Percentage](#adjusting-backup-reserve-percentage) - [Usage](#usage) - [Setup](#setup) - [Authentication](#authentication) @@ -28,6 +28,7 @@ Powerwall Software versions from 1.47.0 to 1.50.1 as well as 20.40 to 22.9.2 are - [Aggregates](#aggregates) - [Current power supply/draw](#current-power-supplydraw) - [Energy exported/imported](#energy-exportedimported) + - [Details](#details) - [Device Type](#device-type) - [Grid Status](#grid-status) - [Operation mode](#operation-mode) @@ -35,6 +36,7 @@ Powerwall Software versions from 1.47.0 to 1.50.1 as well as 20.40 to 22.9.2 are - [Gateway DIN](#gateway-din) - [VIN](#vin) - [Off-grid status](#off-grid-status-set-island-mode) + ## Installation Install the library via pip: @@ -43,6 +45,13 @@ Install the library via pip: $ pip install tesla_powerwall ``` +## Limitations + +### Adjusting Backup Reserve Percentage + +Currently it is not possible to control the Backup Percentage, because you need to be logged in as installer, which requires physical switch toggle. There is an ongoing discussion about a possible solution [here](https://github.com/vloschiavo/powerwall2/issues/55). +However, if you believe there exists a solution, feel free to open an issue detailing the solution. + ## Usage For a basic Overview of the functionality of this library you can take a look at `examples/example.py`: @@ -67,7 +76,7 @@ powerwall = Powerwall( endpoint="", # Configure timeout; default is 10 timeout=10, - # Provide a requests.Session or None to have one created + # Provide a requests.Session or None. If None is provided, a Session will be created. http_session=None, # Whether to verify the SSL certificate or not verify_ssl=False, @@ -82,7 +91,7 @@ powerwall = Powerwall( ### Authentication Since version 20.49.0 authentication is required for all methods. For that reason you must call `login` before making a request to the API. -When you perform a request without being loggedin a `AccessDeniedError` will be thrown. +When you perform a request without being authenticated, an `AccessDeniedError` will be thrown. To login you can either use `login` or `login_as`. `login` logs you in as `User.CUSTOMER` whereas with `login_as` you can choose a different user: @@ -110,6 +119,8 @@ powerwall.is_authenticated() # Logout powerwall.logout() +powerwall.is_authenticated() +#=> False ``` ### General @@ -132,29 +143,6 @@ api.get_system_status_soe() The `Powerwall` objet provides a wrapper around the API and exposes common methods. -#### Errors - -As the powerwall REST API varies widley between version and country it may happen that an attribute may not be included in your response. If that is the case a `MissingAttributeError` will be thrown indicating what attribute wasn't available. - -#### Response - -Responses are usally wrapped inside a `Response` object to provide convenience methods. An Example is the `Meter` class which is a sublass of `Response`. Each `Response` object includes the `response` member which consists of the plain json response. - -```python -from helpers import assert_attribute - -status = powerwall.get_status() -#=> - -status.version -# is the same as -assert_attribute(status.response, "version") -# or -status.assert_attribute("version") -``` - -For retriving the version you could also alternativly use `powerwall.get_version`. - ### Battery level Get charge in percent: @@ -221,9 +209,9 @@ status.device_type ### Sitemaster ```python -sm = powerwall.sitemaster +sm = powerwall.sitemaster #=> -sm.status +sm.status #=> StatusUp sm.running #=> true @@ -264,10 +252,10 @@ meters.get_meter(MeterType.SOLAR) # access meter, but may raise MeterNotAvailableError when the meter is not available at your powerwall (e.g. no solar panels installed) meters.solar -#=> +#=> # get all available meters at the current powerwall -meters.meters +meters.meters.keys() #=> [, , , ] ``` @@ -312,6 +300,30 @@ meters.battery.get_energy_imported() #=> 7576.6 (kWh) ``` +### Details + +You can receive more detailed information about the meters `site` and `solar`: + +```python +meter_details = powerwall.get_meter_site() # or get_meter_solar() for the solar meter +#=> +readings = meter_details.readings +#=> +readings.real_power_a # same for real_power_b and real_power_c +#=> 619.13532458 +readings.i_a_current # same for i_b_current and i_c_current +#=> 3.02 +readings.v_l1n # smae for v_l2n and v_l3n +#=> 235.82 +readings.instant_power +#=> -18.000023458 +readings.is_sending() +``` + +As `MeterDetailsReadings` inherits from `MeterResponse` (which is used in `MetersAggratesResponse`) it exposes the same data and methods. + +> For the meters battery and grid no additional details are provided, therefore no methods exist for those meters + ### Device Type ```python @@ -321,7 +333,7 @@ powerwall.get_device_type() ### Grid Status -Get current grid status. +Get current grid status. ```python powerwall.get_grid_status() @@ -361,7 +373,7 @@ vin = powerwall.get_vin() ### Off-grid status (Set Island mode) -Take your powerwall on- and off-grid similar to the "Take off-grid" button in the Tesla app. +Take your powerwall on- and off-grid similar to the "Take off-grid" button in the Tesla app. #### Set powerwall to off-grid (Islanded) @@ -374,3 +386,27 @@ powerwall.set_island_mode(IslandMode.OFFGRID) ```python powerwall.set_island_mode(IslandMode.ONGRID) ``` + +# Development + +## Building + +```sh +$ python -m build +``` + +## Testing + +### Unit-Tests + +To run unit tests use tox: + +```sh +$ tox -e unit +``` + +### Integration-Tests + +```sh +$ tox -e integration +``` diff --git a/examples/example.py b/examples/example.py index 4ba8099..cb4034b 100644 --- a/examples/example.py +++ b/examples/example.py @@ -1,5 +1,6 @@ import os -from tesla_powerwall import Powerwall, Meter + +from tesla_powerwall import MeterResponse, Powerwall def getenv(var): @@ -9,7 +10,7 @@ def getenv(var): return val -def print_meter_row(meter_data: Meter): +def print_meter_row(meter_data: MeterResponse): print( "{:>8} {:>8} {:>17} {:>17} {!r:>8} {!r:>17} {!r:>17}".format( meter_data.meter.value, @@ -42,7 +43,7 @@ def print_meter_row(meter_data: Meter): ("Grid Status", power_wall.get_grid_status().value), ("Backup Reserve (%)", round(power_wall.get_backup_reserve_percentage())), ("Device Type", power_wall.get_device_type().value), - ("Software Version", power_wall.get_version()) + ("Software Version", power_wall.get_version()), ] @@ -62,5 +63,6 @@ def print_meter_row(meter_data: Meter): "Sending to", ) ) -for meter in meters_agg.meters: - print_meter_row(meters_agg.get_meter(meter)) + +for meter in meters_agg.meters.values(): + print_meter_row(meter) diff --git a/examples/influxDB.py b/examples/influxDB.py deleted file mode 100644 index 6650f07..0000000 --- a/examples/influxDB.py +++ /dev/null @@ -1,47 +0,0 @@ -# This is an Example of how to use the tesla_powerwall API and the influxdb Client to generate and store -# Monitoring Data in a Time Series Database. InfluxDB is natively compatible with https://grafana.com - -#Imports -import os -from time import sleep -import influxdb_client -from influxdb_client.client.write_api import SYNCHRONOUS -from tesla_powerwall import Powerwall - - -# Variables -## InfluxDB -bucket = "" -org = "" -token = "" -url="http://localhost:8086" - -client = influxdb_client.InfluxDBClient( - url=url, - token=token, - org=org -) - -write_api = client.write_api(write_options=SYNCHRONOUS) - -## Powerwall - -ip = os.getenv("POWERWALL_IP") -if ip is None: - raise ValueError("POWERWALL_IP must be set") - -email = os.getenv("POWERWALL_EMAIL") -password = os.getenv("POWERWALL_PASSWORD") - -power_wall = Powerwall(ip) - -# Program -print("Current charge: {}".format(power_wall.get_charge())) -print("Device Type: {}".format(power_wall.get_device_type())) - -## Sending Data - -while True: - p = influxdb_client.Point("Measurement").field("Charge", power_wall.get_charge) - write_api.write(bucket=bucket, org=org, record = p) - sleep(1) diff --git a/pyproject.toml b/pyproject.toml index 9dd4cc8..32158ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,40 @@ [build-system] -requires = ["requests>=2.22.0","setuptools >= 35.0.2"] -build-backend = "setuptools.build_meta" \ No newline at end of file +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "tesla_powerwall" +version = "0.4.0" +description = "A simple API for accessing the Tesla Powerwall over your local network" +readme = "README.md" +license = { file = "LICENSE"} +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", +] +keywords = ["api", "tesla", "powerwall", "tesla_powerwall"] +dependencies = [ + "requests>=2.22.0" +] + +[project.urls] +Homepage = "https://github.com/jrester/tesla_powerwall" + +[project.optional-dependencies] +test = [ + "tox", + "pre-commit", +] + +[tool.ruff] +ignore-init-module-imports = true + +[tool.coverage.run] +source = ["tesla_powerwall"] + +[tool.isort] +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 855fc6e..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[coverage:run] -omit = */.local/* \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 0cf9d2c..0000000 --- a/setup.py +++ /dev/null @@ -1,24 +0,0 @@ -from setuptools import setup, find_packages - -with open("README.md", "r") as fh: - long_description = fh.read() - -setup( - name="tesla_powerwall", - author="Jrester", - author_email="jrester379@gmail.com", - version='0.3.19', - description="API for Tesla Powerwall", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/jrester/tesla_powerwall", - packages=find_packages(exclude=["tests*"]), - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License" - ], - package_data={ - 'tesla_powerwall': ["py.typed"] - }, - install_requires=["requests>=2.22.0"], -) diff --git a/tesla_powerwall/__init__.py b/tesla_powerwall/__init__.py index 1c52c2d..fcc5f30 100644 --- a/tesla_powerwall/__init__.py +++ b/tesla_powerwall/__init__.py @@ -1,6 +1,7 @@ +# ruff: noqa: F401 + from .api import API from .const import ( - DEFAULT_KW_ROUND_PERSICION, SUPPORTED_OPERATION_MODES, DeviceType, GridState, @@ -15,7 +16,7 @@ ) from .error import ( AccessDeniedError, - APIError, + ApiError, MeterNotAvailableError, MissingAttributeError, PowerwallError, @@ -24,14 +25,18 @@ from .helpers import assert_attribute, convert_to_kw from .powerwall import Powerwall from .responses import ( - Battery, + BatteryResponse, LoginResponse, - Meter, - MetersAggregates, - PowerwallStatus, - SiteInfo, - SiteMaster, - Solar, + MeterDetailsReadings, + MeterDetailsResponse, + MeterResponse, + MetersAggregatesResponse, + PowerwallStatusResponse, + SiteInfoResponse, + SiteMasterResponse, + SolarResponse, ) -VERSION = "0.3.19" +VERSION = "0.4.0" + +__all__ = list(filter(lambda n: not n.startswith("_"), globals().keys())) diff --git a/tesla_powerwall/api.py b/tesla_powerwall/api.py index f69aa77..4fc8827 100644 --- a/tesla_powerwall/api.py +++ b/tesla_powerwall/api.py @@ -1,13 +1,13 @@ from http.client import responses from json.decoder import JSONDecodeError -from typing import List +from typing import Any, List, Optional from urllib.parse import urljoin import requests from urllib3 import disable_warnings from urllib3.exceptions import InsecureRequestWarning -from .error import AccessDeniedError, APIError, PowerwallUnreachableError +from .error import AccessDeniedError, ApiError, PowerwallUnreachableError class API(object): @@ -15,11 +15,10 @@ def __init__( self, endpoint: str, timeout: int = 10, - http_session: requests.Session = None, + http_session: Optional[requests.Session] = None, verify_ssl: bool = False, disable_insecure_warning: bool = True, ) -> None: - if disable_insecure_warning: disable_warnings(InsecureRequestWarning) @@ -50,7 +49,7 @@ def _parse_endpoint(endpoint: str) -> str: @staticmethod def _handle_error(response: requests.Response) -> None: if response.status_code == 404: - raise APIError( + raise ApiError( "The url {} returned error 404".format(response.request.path_url) ) @@ -68,7 +67,7 @@ def _handle_error(response: requests.Response) -> None: ) if response.text is not None and len(response.text) > 0: - raise APIError( + raise ApiError( "API returned status code '{}: {}' with body: {}".format( response.status_code, responses.get(response.status_code), @@ -76,7 +75,7 @@ def _handle_error(response: requests.Response) -> None: ) ) else: - raise APIError( + raise ApiError( "API returned status code '{}: {}' ".format( response.status_code, responses.get(response.status_code) ) @@ -93,7 +92,7 @@ def _process_response(self, response: requests.Response) -> dict: try: response_json = response.json() except JSONDecodeError: - raise APIError( + raise ApiError( "Error while decoding json of response: {}".format(response.text) ) @@ -103,14 +102,14 @@ def _process_response(self, response: requests.Response) -> dict: # Newer versions of the powerwall do not return such values anymore # Kept for backwards compability or if the API changes again if "error" in response_json: - raise APIError(response_json["error"]) + raise ApiError(response_json["error"]) return response_json def url(self, path: str): return urljoin(self._endpoint, path) - def get(self, path: str, headers: dict = {}) -> dict: + def get(self, path: str, headers: dict = {}) -> Any: try: response = self._http_session.get( url=self.url(path), @@ -121,7 +120,7 @@ def get(self, path: str, headers: dict = {}) -> dict: requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout, ) as e: - raise PowerwallUnreachableError(e) + raise PowerwallUnreachableError(str(e)) return self._process_response(response) @@ -130,7 +129,7 @@ def post( path: str, payload: dict, headers: dict = {}, - ) -> dict: + ) -> Any: try: response = self._http_session.post( url=self.url(path), @@ -142,7 +141,7 @@ def post( requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout, ) as e: - raise PowerwallUnreachableError(e) + raise PowerwallUnreachableError(str(e)) return self._process_response(response) @@ -150,9 +149,12 @@ def is_authenticated(self) -> bool: return "AuthCookie" in self._http_session.cookies.keys() def login( - self, username: str, email: str, password: str, force_sm_off: bool = False + self, + username: str, + email: str, + password: str, + force_sm_off: bool = False, ) -> dict: - # force_sm_off is referred to as 'shouldForceLogin' in the web source code return self.post( "login/Basic", @@ -166,13 +168,14 @@ def login( def logout(self) -> None: if not self.is_authenticated(): - raise APIError("Must be logged in to log out") + raise ApiError("Must be logged in to log out") # The api unsets the auth cookie and the token is invalidated self.get("logout") def close(self) -> None: # Close the HTTP Session - # THis method is required for testing, so python doesn't complain about unclosed resources + # THis method is required for testing, + # so python doesn't complain about unclosed resources self._http_session.close() # Endpoints are mapped to one method by _ so they can be easily accessed diff --git a/tesla_powerwall/const.py b/tesla_powerwall/const.py index b395b80..0d91383 100644 --- a/tesla_powerwall/const.py +++ b/tesla_powerwall/const.py @@ -25,10 +25,12 @@ class GridStatus(Enum): TRANSITION_TO_GRID = "SystemTransitionToGrid" # Used in version 1.46.0 TRANSITION_TO_ISLAND = "SystemTransitionToIsland" + class IslandMode(Enum): OFFGRID = "intentional_reconnect_failsafe" ONGRID = "backup" + class GridState(Enum): COMPLIANT = "Grid_Compliant" QUALIFINY = "Grid_Qualifying" diff --git a/tesla_powerwall/error.py b/tesla_powerwall/error.py index 95d73d6..42aef92 100644 --- a/tesla_powerwall/error.py +++ b/tesla_powerwall/error.py @@ -8,12 +8,12 @@ def __init__(self, msg: str): super().__init__(msg) -class APIError(PowerwallError): +class ApiError(PowerwallError): def __init__(self, error: str): super().__init__("Powerwall api error: {}".format(error)) -class MissingAttributeError(APIError): +class MissingAttributeError(ApiError): def __init__(self, response: dict, attribute: str, url: Union[str, None] = None): self.response: dict = response self.attribute: str = attribute @@ -27,7 +27,8 @@ def __init__(self, response: dict, attribute: str, url: Union[str, None] = None) ) else: super().__init__( - "The attribute '{}' is expected in the response for '{}' but is missing.".format( + "The attribute '{}' is expected in the response for \ + '{}' but is missing.".format( attribute, url ) ) @@ -66,7 +67,8 @@ def __init__(self, meter: MeterType, available_meters: List[MeterType]): self.meter: MeterType = meter self.available_meters: List[MeterType] = available_meters super().__init__( - "Meter {} is not available at your powerwall. Following meters are available: {} ".format( + "Meter {} is not available at your powerwall. \ + Following meters are available: {} ".format( meter.value, available_meters ) ) diff --git a/tesla_powerwall/powerwall.py b/tesla_powerwall/powerwall.py index eb1f67a..6173888 100644 --- a/tesla_powerwall/powerwall.py +++ b/tesla_powerwall/powerwall.py @@ -3,30 +3,18 @@ import requests from .api import API -from .const import ( - DEFAULT_KW_ROUND_PERSICION, - SUPPORTED_OPERATION_MODES, - DeviceType, - GridState, - GridStatus, - IslandMode, - LineStatus, - MeterType, - OperationMode, - Roles, - SyncType, - User, -) +from .const import DeviceType, GridStatus, IslandMode, OperationMode, User +from .error import ApiError from .helpers import assert_attribute from .responses import ( - Battery, + BatteryResponse, LoginResponse, - Meter, - MetersAggregates, - PowerwallStatus, - SiteInfo, - SiteMaster, - Solar, + MeterDetailsResponse, + MetersAggregatesResponse, + PowerwallStatusResponse, + SiteInfoResponse, + SiteMasterResponse, + SolarResponse, ) @@ -53,7 +41,7 @@ def login_as( password: str, email: str, force_sm_off: bool = False, - ) -> dict: + ) -> LoginResponse: if isinstance(user, User): user = user.value @@ -61,9 +49,11 @@ def login_as( # The api returns an auth cookie which is automatically set # so there is no need to further process the response - return LoginResponse(response) + return LoginResponse.from_dict(response) - def login(self, password: str, email: str = "", force_sm_off: bool = False) -> dict: + def login( + self, password: str, email: str = "", force_sm_off: bool = False + ) -> LoginResponse: return self.login_as(User.CUSTOMER, password, email, force_sm_off) def logout(self) -> None: @@ -83,33 +73,53 @@ def get_charge(self) -> Union[float, int]: def get_energy(self) -> int: return assert_attribute( - self._api.get_system_status(), "nominal_energy_remaining", "system_status" + self._api.get_system_status(), + "nominal_energy_remaining", + "system_status", ) - def get_sitemaster(self) -> SiteMaster: - return SiteMaster(self._api.get_sitemaster()) + def get_sitemaster(self) -> SiteMasterResponse: + return SiteMasterResponse.from_dict(self._api.get_sitemaster()) + + def get_meters(self) -> MetersAggregatesResponse: + return MetersAggregatesResponse.from_dict(self._api.get_meters_aggregates()) + + def get_meter_site(self) -> MeterDetailsResponse: + meter_response = self._api.get_meters_site() + if meter_response is None or len(meter_response) == 0: + raise ApiError("The powerwall returned no values for the site meter") + + return MeterDetailsResponse.from_dict(meter_response[0]) + + def get_meter_solar(self) -> MeterDetailsResponse: + meter_response = self._api.get_meters_solar() + if meter_response is None or len(meter_response) == 0: + raise ApiError("The powerwall returned no values for the solar meter") - def get_meters(self) -> MetersAggregates: - return MetersAggregates(self._api.get_meters_aggregates()) + return MeterDetailsResponse.from_dict(meter_response[0]) def get_grid_status(self) -> GridStatus: """Returns the current grid status.""" status = assert_attribute( - self._api.get_system_status_grid_status(), "grid_status", "grid_status" + self._api.get_system_status_grid_status(), + "grid_status", + "grid_status", ) return GridStatus(status) def get_capacity(self) -> float: return assert_attribute( - self._api.get_system_status(), "nominal_full_pack_energy", "system_status" + self._api.get_system_status(), + "nominal_full_pack_energy", + "system_status", ) - def get_batteries(self) -> List[Battery]: + def get_batteries(self) -> List[BatteryResponse]: batteries = assert_attribute( self._api.get_system_status(), "battery_blocks", "system_status" ) - return [Battery(battery) for battery in batteries] + return [BatteryResponse.from_dict(battery) for battery in batteries] def is_grid_services_active(self) -> bool: return assert_attribute( @@ -118,15 +128,15 @@ def is_grid_services_active(self) -> bool: "grid_status", ) - def get_site_info(self) -> SiteInfo: + def get_site_info(self) -> SiteInfoResponse: """Returns information about the powerwall site""" - return SiteInfo(self._api.get_site_info()) + return SiteInfoResponse.from_dict(self._api.get_site_info()) - def set_site_name(self, site_name: str) -> str: + def set_site_name(self, site_name: str) -> dict: return self._api.post_site_info_site_name({"site_name": site_name}) - def get_status(self) -> PowerwallStatus: - return PowerwallStatus(self._api.get_status()) + def get_status(self) -> PowerwallStatusResponse: + return PowerwallStatusResponse.from_dict(self._api.get_status()) def get_device_type(self) -> DeviceType: """Returns the device type of the powerwall""" @@ -156,14 +166,19 @@ def get_backup_reserve_percentage(self) -> float: self._api.get_operation(), "backup_reserve_percent", "operation" ) - def get_solars(self) -> List[Solar]: - return [Solar(solar) for solar in self._api.get_solars()] + def get_solars(self) -> List[SolarResponse]: + return [SolarResponse.from_dict(solar) for solar in self._api.get_solars()] def get_vin(self) -> str: return assert_attribute(self._api.get_config(), "vin", "config") def set_island_mode(self, mode: IslandMode) -> IslandMode: - return IslandMode(assert_attribute(self._api.post_islanding_mode({"island_mode": mode.value}), "island_mode")) + return IslandMode( + assert_attribute( + self._api.post_islanding_mode({"island_mode": mode.value}), + "island_mode", + ) + ) def get_version(self) -> str: version_str = assert_attribute(self._api.get_status(), "version", "status") diff --git a/tesla_powerwall/responses.py b/tesla_powerwall/responses.py index 16f9d57..1616308 100644 --- a/tesla_powerwall/responses.py +++ b/tesla_powerwall/responses.py @@ -1,82 +1,55 @@ import re +from dataclasses import dataclass from datetime import datetime, timedelta -from typing import List +from typing import Any, Dict, List, Optional from .const import DEFAULT_KW_ROUND_PERSICION, DeviceType, MeterType, Roles from .error import MeterNotAvailableError -from .helpers import assert_attribute, convert_to_kw +from .helpers import convert_to_kw -class Response: - def __init__(self, response: dict) -> None: - self.response = response - - def assert_attribute(self, attr: str) -> any: - return assert_attribute(self.response, attr) +@dataclass +class ResponseBase: + _raw: dict def __repr__(self) -> str: - return str(self.response) - - -class Meter(Response): - """ - Attributes: - - last_communication_time - - instant_power - - instant_reactive_power - - instant_apparent_power - - frequency - - energy_exported - - energy_imported - - instant_average_voltage - - instant_total_current - - i_a_current - - i_b_current - - i_c_current - - timeout - """ - - def __init__(self, meter: MeterType, response) -> None: - self.meter = meter - super().__init__(response) - - @property - def instant_power(self) -> float: - return self.assert_attribute("instant_power") - - @property - def last_communication_time(self) -> str: - return self.assert_attribute("last_communication_time") - - @property - def frequency(self) -> float: - return self.assert_attribute("frequency") - - @property - def energy_exported(self) -> float: - return self.assert_attribute("energy_exported") + return str(self._raw) + + +@dataclass +class MeterResponse(ResponseBase): + meter: MeterType + instant_power: float + last_communication_time: str + frequency: float + energy_exported: float + energy_imported: float + instant_total_current: float + instant_average_voltage: float + + @staticmethod + def from_dict(meter: MeterType, src: dict) -> "MeterResponse": + return MeterResponse( + src, + meter=meter, + instant_power=src["instant_power"], + last_communication_time=src["last_communication_time"], + frequency=src["frequency"], + energy_exported=src["energy_exported"], + energy_imported=src["energy_imported"], + instant_total_current=src["instant_total_current"], + instant_average_voltage=src["instant_average_voltage"], + ) def get_energy_exported(self, precision=DEFAULT_KW_ROUND_PERSICION) -> float: return convert_to_kw(self.energy_exported, precision) - @property - def energy_imported(self) -> float: - return self.assert_attribute("energy_imported") - def get_energy_imported(self, precision=DEFAULT_KW_ROUND_PERSICION) -> float: return convert_to_kw(self.energy_imported, precision) - @property - def instant_total_current(self) -> float: - return self.assert_attribute("instant_total_current") - def get_instant_total_current(self, precision=DEFAULT_KW_ROUND_PERSICION) -> float: return round(self.instant_total_current, precision) - @property - def average_voltage(self) -> float: - return self.assert_attribute("instant_average_voltage") - def get_power(self, precision=DEFAULT_KW_ROUND_PERSICION) -> float: return convert_to_kw(self.instant_power, precision) @@ -98,242 +71,218 @@ def is_sending_to(self, precision=DEFAULT_KW_ROUND_PERSICION) -> bool: return self.get_power(precision) < 0 -class MetersAggregates(Response): - def __init__(self, response) -> str: - super().__init__(response) - self.meters = [MeterType(key) for key in response.keys()] - - def __getattribute__(self, attr) -> any: +@dataclass +class MeterDetailsReadings(MeterResponse): + real_power_a: Optional[float] + real_power_b: Optional[float] + real_power_c: Optional[float] + + i_a_current: Optional[float] + i_b_current: Optional[float] + i_c_current: Optional[float] + + v_l1n: Optional[float] + v_l2n: Optional[float] + v_l3n: Optional[float] + + @staticmethod + def from_dict(meter: MeterType, src: dict) -> "MeterDetailsReadings": + meter_response = MeterResponse.from_dict(meter, src) + return MeterDetailsReadings( + real_power_a=src.get("real_power_a"), + real_power_b=src.get("real_power_b"), + real_power_c=src.get("real_power_c"), + i_a_current=src.get("i_a_current"), + i_b_current=src.get("i_b_current"), + i_c_current=src.get("i_c_current"), + v_l1n=src.get("v_l1n"), + v_l2n=src.get("v_l2n"), + v_l3n=src.get("v_l3n"), + # Populate with the values from the base class + **meter_response.__dict__ + ) + + +@dataclass +class MeterDetailsResponse(ResponseBase): + location: MeterType + readings: MeterDetailsReadings + + @staticmethod + def from_dict(src: dict) -> "MeterDetailsResponse": + location = MeterType(src["location"]) + readings = MeterDetailsReadings.from_dict(location, src["Cached_readings"]) + return MeterDetailsResponse(src, location=location, readings=readings) + + +class MetersAggregatesResponse(ResponseBase): + @staticmethod + def from_dict(src: dict) -> "MetersAggregatesResponse": + meters = { + MeterType(key): MeterResponse.from_dict(MeterType(key), value) + for key, value in src.items() + } + return MetersAggregatesResponse(src, meters) + + def __init__(self, response: dict, meters: Dict[MeterType, MeterResponse]) -> None: + self._raw = response + self.meters = meters + + def __getattribute__(self, attr) -> Any: if attr.upper() in MeterType.__dict__: m = MeterType(attr) if m in self.meters: - return self.get_meter(m) + return self.meters[m] else: - raise MeterNotAvailableError(m, self.meters) + raise MeterNotAvailableError(m, list(self.meters.keys())) else: return object.__getattribute__(self, attr) - def get_meter(self, meter: MeterType) -> Meter: - if meter in self.meters: - return Meter(meter, self.assert_attribute(meter.value)) - else: - return None - - -class SiteMaster(Response): - """ - Attributes: - - running - - connected_to_tesla - - status - - power_supply_mode - """ - - def __init__(self, response) -> None: - super().__init__(response) - - @property - def status(self) -> str: - return self.assert_attribute("status") - - @property - def is_running(self) -> bool: - return self.assert_attribute("running") - - @property - def is_connected_to_tesla(self) -> bool: - return self.assert_attribute("connected_to_tesla") - - @property - def is_power_supply_mode(self) -> bool: - return self.assert_attribute("power_supply_mode") - - -class SiteInfo(Response): - """ - Attributes: - - max_site_meter_power_kW - - min_site_meter_power_kW - - nominal_system_energy_kWh - - nominal_system_power_kW - - max_system_energy_kWh - - max_system_power_kW - - site_name - - timezone - - grid_code - """ - - def __init__(self, response) -> None: - super().__init__(response) - - @property - def nominal_system_energy(self) -> int: - return self.assert_attribute("nominal_system_energy_kWh") - - @property - def site_name(self) -> str: - return self.assert_attribute("site_name") - - @property - def timezone(self) -> str: - return self.assert_attribute("timezone") - - -class PowerwallStatus(Response): - """ - Attributes: - * start_time - * up_time_seconds - * is_new - * version - * device_type - * commission_count - * sync_type - * git_hash - """ + def get_meter(self, meter: MeterType) -> Optional[MeterResponse]: + return self.meters.get(meter) + + +@dataclass +class SiteMasterResponse(ResponseBase): + status: str + is_running: bool + is_connected_to_tesla: bool + is_power_supply_mode: bool + + @staticmethod + def from_dict(src: dict) -> "SiteMasterResponse": + return SiteMasterResponse( + src, + status=src["status"], + is_running=src["running"], + is_connected_to_tesla=src["connected_to_tesla"], + is_power_supply_mode=src["power_supply_mode"], + ) + + +@dataclass +class SiteInfoResponse(ResponseBase): + nominal_system_energy: int + nominal_system_power: int + site_name: str + timezone: str + + @staticmethod + def from_dict(src: dict) -> "SiteInfoResponse": + return SiteInfoResponse( + src, + nominal_system_energy=src["nominal_system_energy_kWh"], + nominal_system_power=src["nominal_system_power_kW"], + site_name=src["site_name"], + timezone=src["timezone"], + ) + + +@dataclass +class PowerwallStatusResponse(ResponseBase): + start_time: datetime + up_time_seconds: timedelta + version: str + device_type: DeviceType + commission_count: int + sync_type: str + git_hash: str _START_TIME_FORMAT = "%Y-%m-%d %H:%M:%S %z" _UP_TIME_SECONDS_REGEX = re.compile( r"^((?P[\.\d]+?)d)?((?P[\.\d]+?)h)?((?P[\.\d]+?)m)?((?P[\.\d]+?)s)?$" ) - def _parse_uptime_seconds(self, up_time_seconds: str) -> timedelta: - match = PowerwallStatus._UP_TIME_SECONDS_REGEX.match(up_time_seconds) + @staticmethod + def _parse_uptime_seconds(up_time_seconds: str) -> timedelta: + match = PowerwallStatusResponse._UP_TIME_SECONDS_REGEX.match(up_time_seconds) if not match: raise ValueError( "Unable to parse up time seconds {}".format(up_time_seconds) ) time_params = {} - for (name, param) in match.groupdict().items(): + for name, param in match.groupdict().items(): if param: time_params[name] = float(param) return timedelta(**time_params) - @property - def up_time_seconds(self) -> timedelta: - up_time_seconds = assert_attribute(self.response, "up_time_seconds") - return self._parse_uptime_seconds(up_time_seconds) - - @property - def start_time(self) -> datetime: - start_time = assert_attribute(self.response, "start_time") - return datetime.strptime(start_time, self._START_TIME_FORMAT) - - @property - def version(self) -> str: - return self.assert_attribute("version") - - @property - def device_type(self) -> DeviceType: - return DeviceType(self.assert_attribute("device_type")) - - -class LoginResponse(Response): - """ - Attributes - - email - - firstname - - lastname - - roles - - token - - provider - - loginTime - """ - - @property - def firstname(self) -> str: - return self.assert_attribute("firstname") - - @property - def lastname(self) -> str: - return self.assert_attribute("lastname") - - @property - def token(self) -> str: - return self.assert_attribute("token") - - @property - def roles(self) -> List[Roles]: - return [Roles(role) for role in self.assert_attribute("roles")] - - @property - def login_time(self): - return self.assert_attribute("loginTime") - - -class Solar(Response): - """ - Attributes - - brand - - model - - power_rating_watts - """ - - @property - def brand(self) -> str: - return self.assert_attribute("brand") - - @property - def model(self) -> str: - return self.assert_attribute("model") - - @property - def power_rating_watts(self) -> int: - return self.assert_attribute("power_rating_watts") - - -class Battery(Response): - @property - def part_number(self) -> str: - return self.assert_attribute("PackagePartNumber") - - @property - def serial_number(self) -> str: - return self.assert_attribute("PackageSerialNumber") - - @property - def energy_charged(self) -> int: - """get the amount of energy that was ever charged - - Returns: - int: energy in watts - """ - return self.assert_attribute("energy_charged") - - @property - def energy_discharged(self) -> int: - """get the amount of energy that was ever discharged - - Returns: - int: energy in watts - """ - return self.assert_attribute("energy_discharged") - - @property - def energy_remaining(self) -> int: - """get the remaining charged energy - - Returns: - int: energy in watts - """ - return self.assert_attribute("nominal_energy_remaining") - - @property - def capacity(self) -> int: - """get the capacity of a battery - - Returns: - int: energy in watts - """ - return self.assert_attribute("nominal_full_pack_energy") - - @property - def wobble_detected(self) -> bool: - """get whether a wobble was detected - - Returns: - bool: detected - """ - return self.assert_attribute("wobble_detected") + @staticmethod + def from_dict(src: dict) -> "PowerwallStatusResponse": + start_time = datetime.strptime( + src["start_time"], PowerwallStatusResponse._START_TIME_FORMAT + ) + up_time_seconds = PowerwallStatusResponse._parse_uptime_seconds( + src["up_time_seconds"] + ) + return PowerwallStatusResponse( + src, + start_time=start_time, + up_time_seconds=up_time_seconds, + version=src["version"], + device_type=DeviceType(src["device_type"]), + commission_count=src["commission_count"], + sync_type=src["sync_type"], + git_hash=src["git_hash"], + ) + + +@dataclass +class LoginResponse(ResponseBase): + firstname: str + lastname: str + token: str + roles: List[Roles] + login_time: str + + @staticmethod + def from_dict(src: dict) -> "LoginResponse": + return LoginResponse( + src, + firstname=src["firstname"], + lastname=src["lastname"], + token=src["token"], + roles=[Roles(role) for role in src["roles"]], + login_time=src["loginTime"], + ) + + +@dataclass +class SolarResponse(ResponseBase): + brand: str + model: str + power_rating_watts: int + + @staticmethod + def from_dict(src: dict) -> "SolarResponse": + return SolarResponse( + src, + brand=src["brand"], + model=src["model"], + power_rating_watts=src["power_rating_watts"], + ) + + +@dataclass +class BatteryResponse(ResponseBase): + part_number: str + serial_number: str + energy_charged: int + energy_discharged: int + energy_remaining: int + capacity: int + wobble_detected: bool + + @staticmethod + def from_dict(src: dict) -> "BatteryResponse": + return BatteryResponse( + src, + part_number=src["PackagePartNumber"], + serial_number=src["PackageSerialNumber"], + energy_charged=src["energy_charged"], + energy_discharged=src["energy_discharged"], + energy_remaining=src["nominal_energy_remaining"], + capacity=src["nominal_full_pack_energy"], + wobble_detected=src["wobble_detected"], + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_powerwall.py b/tests/integration/test_powerwall.py index a5b3a89..2fe46e3 100644 --- a/tests/integration/test_powerwall.py +++ b/tests/integration/test_powerwall.py @@ -1,17 +1,14 @@ import unittest from time import sleep -from tesla_powerwall import ( - GridStatus, - IslandMode, - Meter, - MetersAggregates, - MeterType, - Powerwall, - SiteInfo, - SiteMaster, +from tesla_powerwall import GridStatus, IslandMode, MeterType, Powerwall +from tesla_powerwall.responses import ( + MeterResponse, + MetersAggregatesResponse, + PowerwallStatusResponse, + SiteInfoResponse, + SiteMasterResponse, ) -from tesla_powerwall.responses import PowerwallStatus from tests.integration import POWERWALL_IP, POWERWALL_PASSWORD @@ -25,25 +22,26 @@ def tearDown(self) -> None: def test_get_charge(self) -> None: charge = self.powerwall.get_charge() - if(charge < 100): + if charge < 100: self.assertIsInstance(charge, float) else: self.assertEqual(charge, 100) def test_get_meters(self) -> None: meters = self.powerwall.get_meters() - self.assertIsInstance(meters, MetersAggregates) + self.assertIsInstance(meters, MetersAggregatesResponse) - self.assertIsInstance(meters.get_meter(MeterType.BATTERY), Meter) + self.assertIsInstance(meters.get_meter(MeterType.BATTERY), MeterResponse) for meter_type in meters.meters: meter = meters.get_meter(meter_type) + assert meter is not None meter.energy_exported meter.energy_imported meter.instant_power meter.last_communication_time meter.frequency - meter.average_voltage + meter.instant_average_voltage meter.get_energy_exported() meter.get_energy_imported() self.assertIsInstance(meter.get_power(), float) @@ -54,7 +52,7 @@ def test_get_meters(self) -> None: def test_sitemaster(self) -> None: sitemaster = self.powerwall.get_sitemaster() - self.assertIsInstance(sitemaster, SiteMaster) + self.assertIsInstance(sitemaster, SiteMasterResponse) sitemaster.status sitemaster.is_running @@ -64,7 +62,7 @@ def test_sitemaster(self) -> None: def test_site_info(self) -> None: site_info = self.powerwall.get_site_info() - self.assertIsInstance(site_info, SiteInfo) + self.assertIsInstance(site_info, SiteInfoResponse) site_info.nominal_system_energy site_info.site_name @@ -94,7 +92,7 @@ def test_grid_status(self) -> None: def test_status(self) -> None: status = self.powerwall.get_status() - self.assertIsInstance(status, PowerwallStatus) + self.assertIsInstance(status, PowerwallStatusResponse) status.up_time_seconds status.start_time status.version @@ -103,10 +101,10 @@ def test_islanding(self) -> None: initial_grid_status = self.powerwall.get_grid_status() self.assertIsInstance(initial_grid_status, GridStatus) - if(initial_grid_status == GridStatus.CONNECTED): + if initial_grid_status == GridStatus.CONNECTED: self.go_offline() self.go_online() - elif(initial_grid_status == GridStatus.ISLANDED): + elif initial_grid_status == GridStatus.ISLANDED: self.go_offline() self.go_online() @@ -122,13 +120,18 @@ def go_online(self) -> None: self.wait_until_grid_status(GridStatus.CONNECTED) self.assertEqual(self.powerwall.get_grid_status(), GridStatus.CONNECTED) - def wait_until_grid_status(self, expectedStatus: GridStatus, sleepTime: int = 1, maxCycles: int = 20) -> None: + def wait_until_grid_status( + self, + expectedStatus: GridStatus, + sleepTime: int = 1, + maxCycles: int = 20, + ) -> None: cycles = 0 observedStatus: GridStatus while cycles < maxCycles: observedStatus = self.powerwall.get_grid_status() - if(observedStatus == expectedStatus): + if observedStatus == expectedStatus: break sleep(sleepTime) cycles = cycles + 1 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index 1ccf1a0..f121edb 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -1,18 +1,26 @@ import json -import os +from pathlib import Path ENDPOINT = "https://1.1.1.1/api/" -PREFIX = "tests/unit/fixtures" +FIXTURE_BASE_PATH = Path("tests/unit/fixtures") -def _load_fixtures(): - for file in os.listdir("tests/unit/fixtures"): - if file.endswith(".json"): - file_path = os.path.join(PREFIX, file) - with open(file_path) as f: - name = file[:-5].upper() + "_RESPONSE" - globals()[name] = json.loads(f.read()) +def load_fixture(name: str): + path = FIXTURE_BASE_PATH / name + with open(path) as f: + return json.load(f) -_load_fixtures() +GRID_STATUS_RESPONSE = load_fixture("grid_status.json") +ISLANDING_MODE_OFFGRID_RESPONSE = load_fixture("islanding_mode_offgrid.json") +ISLANDING_MODE_ONGRID_RESPONSE = load_fixture("islanding_mode_ongrid.json") +METER_SITE_RESPONSE = load_fixture("meter_site.json") +METER_SOLAR_RESPONSE = load_fixture("meter_solar.json") +METERS_AGGREGATES_RESPONSE = load_fixture("meters_aggregates.json") +OPERATION_RESPONSE = load_fixture("operation.json") +POWERWALLS_RESPONSE = load_fixture("powerwalls.json") +SITE_INFO_RESPONSE = load_fixture("site_info.json") +SITEMASTER_RESPONSE = load_fixture("sitemaster.json") +STATUS_RESPONSE = load_fixture("status.json") +SYSTEM_STATUS_RESPONSE = load_fixture("system_status.json") diff --git a/tests/unit/fixtures/grid_status.json b/tests/unit/fixtures/grid_status.json index 67eabc7..d6ed8f0 100644 --- a/tests/unit/fixtures/grid_status.json +++ b/tests/unit/fixtures/grid_status.json @@ -1 +1,4 @@ -{"grid_status": "SystemGridConnected", "grid_services_active": false} \ No newline at end of file +{ + "grid_services_active": false, + "grid_status": "SystemGridConnected" +} diff --git a/tests/unit/fixtures/islanding_mode_offgrid.json b/tests/unit/fixtures/islanding_mode_offgrid.json index 7a1a9cd..a2ad891 100644 --- a/tests/unit/fixtures/islanding_mode_offgrid.json +++ b/tests/unit/fixtures/islanding_mode_offgrid.json @@ -1 +1,3 @@ -{"island_mode": "intentional_reconnect_failsafe"} \ No newline at end of file +{ + "island_mode": "intentional_reconnect_failsafe" +} diff --git a/tests/unit/fixtures/islanding_mode_ongrid.json b/tests/unit/fixtures/islanding_mode_ongrid.json index 6db2c51..b83a928 100644 --- a/tests/unit/fixtures/islanding_mode_ongrid.json +++ b/tests/unit/fixtures/islanding_mode_ongrid.json @@ -1 +1,3 @@ -{"island_mode": "backup"} \ No newline at end of file +{ + "island_mode": "backup" +} diff --git a/tests/unit/fixtures/meter_site.json b/tests/unit/fixtures/meter_site.json new file mode 100644 index 0000000..98a583d --- /dev/null +++ b/tests/unit/fixtures/meter_site.json @@ -0,0 +1,58 @@ +[ + { + "Cached_readings": { + "energy_exported": 1152130.009160081, + "energy_exported_a": 1192873.1919444446, + "energy_exported_b": 1713.8994444444445, + "energy_imported": 15614764.694437858, + "energy_imported_a": 15654958.373333333, + "energy_imported_b": 6.539722222222222, + "frequency": 50, + "i_a_current": 0, + "i_b_current": 0, + "i_c_current": 0, + "instant_apparent_power": 431.96520416598025, + "instant_average_current": 0, + "instant_average_voltage": 123.87999878078699, + "instant_power": -18.00000076368451, + "instant_reactive_power": -431.5900109857321, + "instant_total_current": 0, + "last_communication_time": "2023-04-08T12:55:00.180984196+01:00", + "last_phase_energy_communication_time": "2023-04-07T14:44:17.625943287+01:00", + "last_phase_power_communication_time": "2023-04-08T12:55:00.180984196+01:00", + "last_phase_voltage_communication_time": "2023-04-08T12:55:00.181027196+01:00", + "reactive_power_a": -431.4800109863281, + "reactive_power_b": -0.10999999940395355, + "real_power_a": -17.950000762939453, + "real_power_b": -0.05000000074505806, + "serial_number": "OBB1234567890", + "timeout": 1500000000, + "v_l1n": 247.55999755859375, + "v_l2n": 0.20000000298023224, + "version": "1.7.1-Tesla" + }, + "connection": { + "device_serial": "OBB1234567890", + "https_conf": {}, + "ip_address": "PWRview-12345", + "neurio_connected": true, + "port": 443, + "short_id": "12345" + }, + "cts": [ + true, + true, + false, + false + ], + "id": 0, + "inverted": [ + false, + false, + false, + false + ], + "location": "site", + "type": "neurio_tcp" + } +] diff --git a/tests/unit/fixtures/meter_solar.json b/tests/unit/fixtures/meter_solar.json new file mode 100644 index 0000000..07a336e --- /dev/null +++ b/tests/unit/fixtures/meter_solar.json @@ -0,0 +1,53 @@ +[ + { + "Cached_readings": { + "energy_exported": 30088.217501777115, + "energy_exported_a": 30104.78111111111, + "energy_imported": 18346702.535001777, + "energy_imported_a": 18336727.701388888, + "frequency": 49.95000076293945, + "i_a_current": 0, + "i_b_current": 0, + "i_c_current": 0, + "instant_apparent_power": 2869.7488146485034, + "instant_average_current": 0, + "instant_average_voltage": 250.72000122070312, + "instant_power": 2867.159912109375, + "instant_reactive_power": -121.87000274658203, + "instant_total_current": 0, + "last_communication_time": "2023-04-08T13:01:26.68807076+01:00", + "last_phase_energy_communication_time": "2023-04-08T08:02:36.955494546+01:00", + "last_phase_power_communication_time": "2023-04-08T13:01:26.68807076+01:00", + "last_phase_voltage_communication_time": "2023-04-08T13:01:26.68811376+01:00", + "reactive_power_a": -121.87000274658203, + "real_power_a": 2867.159912109375, + "serial_number": "OBB1234567890", + "timeout": 1500000000, + "v_l1n": 250.72000122070312, + "version": "1.7.1-Tesla" + }, + "connection": { + "device_serial": "OBB1234567890", + "https_conf": {}, + "ip_address": "PWRview-12345", + "neurio_connected": true, + "port": 443, + "short_id": "12345" + }, + "cts": [ + false, + false, + false, + true + ], + "id": 0, + "inverted": [ + false, + false, + false, + false + ], + "location": "solar", + "type": "neurio_tcp" + } +] diff --git a/tests/unit/fixtures/meters_aggregates.json b/tests/unit/fixtures/meters_aggregates.json index 2dc0e44..6b6e096 100644 --- a/tests/unit/fixtures/meters_aggregates.json +++ b/tests/unit/fixtures/meters_aggregates.json @@ -1 +1,62 @@ -{"site": {"last_communication_time": "2020-04-09T05:50:38.989687241-07:00", "instant_power": -5347.455078125, "instant_reactive_power": -664.1942901611328, "instant_apparent_power": 5388.546173843879, "frequency": 49.99971389770508, "energy_exported": 5512641.122754764, "energy_imported": 9852397.795532543, "instant_average_voltage": 232.0439249674479, "instant_total_current": 0, "i_a_current": 0, "i_b_current": 0, "i_c_current": 0, "timeout": 1500000000}, "battery": {"last_communication_time": "2020-04-09T05:50:38.990165237-07:00", "instant_power": -10, "instant_reactive_power": 600, "instant_apparent_power": 600.0833275470999, "frequency": 49.995000000000005, "energy_exported": 4379890, "energy_imported": 5265110, "instant_average_voltage": 230.8, "instant_total_current": -0.4, "i_a_current": 0, "i_b_current": 0, "i_c_current": 0, "timeout": 1500000000}, "load": {"last_communication_time": "2020-04-09T05:50:38.974944676-07:00", "instant_power": 734.1549565813606, "instant_reactive_power": -469.988307011022, "instant_apparent_power": 871.7066645380579, "frequency": 49.99971389770508, "energy_exported": 0, "energy_imported": 24751111.13611111, "instant_average_voltage": 232.0439249674479, "instant_total_current": 3.1638620001982423, "i_a_current": 0, "i_b_current": 0, "i_c_current": 0, "timeout": 1500000000}, "solar": {"last_communication_time": "2020-04-09T05:50:38.974944676-07:00", "instant_power": 6099.032958984375, "instant_reactive_power": -422.27491760253906, "instant_apparent_power": 6113.633873631454, "frequency": 49.95012283325195, "energy_exported": 21296639.987777833, "energy_imported": 65.52444450131091, "instant_average_voltage": 232.1537322998047, "instant_total_current": 0, "i_a_current": 0, "i_b_current": 0, "i_c_current": 0, "timeout": 1500000000}} \ No newline at end of file +{ + "battery": { + "energy_exported": 4379890, + "energy_imported": 5265110, + "frequency": 49.995000000000005, + "i_a_current": 0, + "i_b_current": 0, + "i_c_current": 0, + "instant_apparent_power": 600.0833275470999, + "instant_average_voltage": 230.8, + "instant_power": -10, + "instant_reactive_power": 600, + "instant_total_current": -0.4, + "last_communication_time": "2020-04-09T05:50:38.990165237-07:00", + "timeout": 1500000000 + }, + "load": { + "energy_exported": 0, + "energy_imported": 24751111.13611111, + "frequency": 49.99971389770508, + "i_a_current": 0, + "i_b_current": 0, + "i_c_current": 0, + "instant_apparent_power": 871.7066645380579, + "instant_average_voltage": 232.0439249674479, + "instant_power": 734.1549565813606, + "instant_reactive_power": -469.988307011022, + "instant_total_current": 3.1638620001982423, + "last_communication_time": "2020-04-09T05:50:38.974944676-07:00", + "timeout": 1500000000 + }, + "site": { + "energy_exported": 5512641.122754764, + "energy_imported": 9852397.795532543, + "frequency": 49.99971389770508, + "i_a_current": 0, + "i_b_current": 0, + "i_c_current": 0, + "instant_apparent_power": 5388.546173843879, + "instant_average_voltage": 232.0439249674479, + "instant_power": -5347.455078125, + "instant_reactive_power": -664.1942901611328, + "instant_total_current": 0, + "last_communication_time": "2020-04-09T05:50:38.989687241-07:00", + "timeout": 1500000000 + }, + "solar": { + "energy_exported": 21296639.987777833, + "energy_imported": 65.52444450131091, + "frequency": 49.95012283325195, + "i_a_current": 0, + "i_b_current": 0, + "i_c_current": 0, + "instant_apparent_power": 6113.633873631454, + "instant_average_voltage": 232.1537322998047, + "instant_power": 6099.032958984375, + "instant_reactive_power": -422.27491760253906, + "instant_total_current": 0, + "last_communication_time": "2020-04-09T05:50:38.974944676-07:00", + "timeout": 1500000000 + } +} diff --git a/tests/unit/fixtures/operation.json b/tests/unit/fixtures/operation.json index d30e46c..5a74de6 100644 --- a/tests/unit/fixtures/operation.json +++ b/tests/unit/fixtures/operation.json @@ -1 +1,6 @@ -{"real_mode": "self_consumption", "backup_reserve_percent": 5.000019999999999, "freq_shift_load_shed_soe": 0, "freq_shift_load_shed_delta_f": 0} \ No newline at end of file +{ + "backup_reserve_percent": 5.000019999999999, + "freq_shift_load_shed_delta_f": 0, + "freq_shift_load_shed_soe": 0, + "real_mode": "self_consumption" +} diff --git a/tests/unit/fixtures/powerwalls.json b/tests/unit/fixtures/powerwalls.json index 20700dd..6e4a457 100644 --- a/tests/unit/fixtures/powerwalls.json +++ b/tests/unit/fixtures/powerwalls.json @@ -1 +1,216 @@ -{"enumerating": false, "gateway_din": "gateway_din", "updating": false, "checking_if_offgrid": false, "running_phase_detection": false, "phase_detection_last_error": "phase detection not run", "bubble_shedding": false, "on_grid_check_error": "on grid check not run", "grid_qualifying": false, "grid_code_validating": false, "phase_detection_not_available": true, "powerwalls": [{"Type": "", "PackagePartNumber": "PartNumber1", "PackageSerialNumber": "SerialNumber1", "type": "acpw", "grid_state": "Grid_Uncompliant", "grid_reconnection_time_seconds": 0, "under_phase_detection": false, "updating": false, "commissioning_diagnostic": {"name": "Commissioning", "category": "InternalComms", "disruptive": false, "inputs": null, "checks": [{"name": "CAN connectivity", "status": "fail", "start_time": "2020-10-29T15:02:46.361506132+01:00", "end_time": "2020-10-29T15:02:46.361509132+01:00", "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", "results": {}, "debug": {}}, {"name": "Enable switch", "status": "fail", "start_time": "2020-10-29T15:02:46.361511798+01:00", "end_time": "2020-10-29T15:02:46.361513798+01:00", "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", "results": {}, "debug": {}}, {"name": "Internal communications", "status": "fail", "start_time": "2020-10-29T15:02:46.361516132+01:00", "end_time": "2020-10-29T15:02:46.361518132+01:00", "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", "results": {}, "debug": {}}, {"name": "Firmware up-to-date", "status": "fail", "start_time": "2020-10-29T15:02:46.361520132+01:00", "end_time": "2020-10-29T15:02:46.361522132+01:00", "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", "results": {}, "debug": {}}]}, "update_diagnostic": {"name": "Firmware Update", "category": "InternalComms", "disruptive": true, "inputs": null, "checks": [{"name": "Powerwall firmware", "status": "not_run", "start_time": null, "end_time": null, "progress": 0, "results": null, "debug": null}, {"name": "Battery firmware", "status": "not_run", "start_time": null, "end_time": null, "progress": 0, "results": null, "debug": null}, {"name": "Inverter firmware", "status": "not_run", "start_time": null, "end_time": null, "progress": 0, "results": null, "debug": null}, {"name": "Grid code", "status": "not_run", "start_time": null, "end_time": null, "progress": 0, "results": null, "debug": null}]}, "bc_type": null}, {"Type": "", "PackagePartNumber": "PartNumber2", "PackageSerialNumber": "SerialNumber2", "type": "acpw", "grid_state": "Grid_Uncompliant", "grid_reconnection_time_seconds": 0, "under_phase_detection": false, "updating": false, "commissioning_diagnostic": {"name": "Commissioning", "category": "InternalComms", "disruptive": false, "inputs": null, "checks": [{"name": "CAN connectivity", "status": "fail", "start_time": "2020-10-29T15:02:46.361754463+01:00", "end_time": "2020-10-29T15:02:46.361757797+01:00", "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", "results": {}, "debug": {}}, {"name": "Enable switch", "status": "fail", "start_time": "2020-10-29T15:02:46.36176013+01:00", "end_time": "2020-10-29T15:02:46.36176213+01:00", "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", "results": {}, "debug": {}}, {"name": "Internal communications", "status": "fail", "start_time": "2020-10-29T15:02:46.36176413+01:00", "end_time": "2020-10-29T15:02:46.36176713+01:00", "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", "results": {}, "debug": {}}, {"name": "Firmware up-to-date", "status": "fail", "start_time": "2020-10-29T15:02:46.361769463+01:00", "end_time": "2020-10-29T15:02:46.361771463+01:00", "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", "results": {}, "debug": {}}]}, "update_diagnostic": {"name": "Firmware Update", "category": "InternalComms", "disruptive": true, "inputs": null, "checks": [{"name": "Powerwall firmware", "status": "not_run", "start_time": null, "end_time": null, "progress": 0, "results": null, "debug": null}, {"name": "Battery firmware", "status": "not_run", "start_time": null, "end_time": null, "progress": 0, "results": null, "debug": null}, {"name": "Inverter firmware", "status": "not_run", "start_time": null, "end_time": null, "progress": 0, "results": null, "debug": null}, {"name": "Grid code", "status": "not_run", "start_time": null, "end_time": null, "progress": 0, "results": null, "debug": null}]}, "bc_type": null}], "has_sync": false, "sync": null, "states": []} \ No newline at end of file +{ + "bubble_shedding": false, + "checking_if_offgrid": false, + "enumerating": false, + "gateway_din": "gateway_din", + "grid_code_validating": false, + "grid_qualifying": false, + "has_sync": false, + "on_grid_check_error": "on grid check not run", + "phase_detection_last_error": "phase detection not run", + "phase_detection_not_available": true, + "powerwalls": [ + { + "PackagePartNumber": "PartNumber1", + "PackageSerialNumber": "SerialNumber1", + "Type": "", + "bc_type": null, + "commissioning_diagnostic": { + "category": "InternalComms", + "checks": [ + { + "debug": {}, + "end_time": "2020-10-29T15:02:46.361509132+01:00", + "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", + "name": "CAN connectivity", + "results": {}, + "start_time": "2020-10-29T15:02:46.361506132+01:00", + "status": "fail" + }, + { + "debug": {}, + "end_time": "2020-10-29T15:02:46.361513798+01:00", + "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", + "name": "Enable switch", + "results": {}, + "start_time": "2020-10-29T15:02:46.361511798+01:00", + "status": "fail" + }, + { + "debug": {}, + "end_time": "2020-10-29T15:02:46.361518132+01:00", + "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", + "name": "Internal communications", + "results": {}, + "start_time": "2020-10-29T15:02:46.361516132+01:00", + "status": "fail" + }, + { + "debug": {}, + "end_time": "2020-10-29T15:02:46.361522132+01:00", + "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", + "name": "Firmware up-to-date", + "results": {}, + "start_time": "2020-10-29T15:02:46.361520132+01:00", + "status": "fail" + } + ], + "disruptive": false, + "inputs": null, + "name": "Commissioning" + }, + "grid_reconnection_time_seconds": 0, + "grid_state": "Grid_Uncompliant", + "type": "acpw", + "under_phase_detection": false, + "update_diagnostic": { + "category": "InternalComms", + "checks": [ + { + "debug": null, + "end_time": null, + "name": "Powerwall firmware", + "progress": 0, + "results": null, + "start_time": null, + "status": "not_run" + }, + { + "debug": null, + "end_time": null, + "name": "Battery firmware", + "progress": 0, + "results": null, + "start_time": null, + "status": "not_run" + }, + { + "debug": null, + "end_time": null, + "name": "Inverter firmware", + "progress": 0, + "results": null, + "start_time": null, + "status": "not_run" + }, + { + "debug": null, + "end_time": null, + "name": "Grid code", + "progress": 0, + "results": null, + "start_time": null, + "status": "not_run" + } + ], + "disruptive": true, + "inputs": null, + "name": "Firmware Update" + }, + "updating": false + }, + { + "PackagePartNumber": "PartNumber2", + "PackageSerialNumber": "SerialNumber2", + "Type": "", + "bc_type": null, + "commissioning_diagnostic": { + "category": "InternalComms", + "checks": [ + { + "debug": {}, + "end_time": "2020-10-29T15:02:46.361757797+01:00", + "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", + "name": "CAN connectivity", + "results": {}, + "start_time": "2020-10-29T15:02:46.361754463+01:00", + "status": "fail" + }, + { + "debug": {}, + "end_time": "2020-10-29T15:02:46.36176213+01:00", + "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", + "name": "Enable switch", + "results": {}, + "start_time": "2020-10-29T15:02:46.36176013+01:00", + "status": "fail" + }, + { + "debug": {}, + "end_time": "2020-10-29T15:02:46.36176713+01:00", + "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", + "name": "Internal communications", + "results": {}, + "start_time": "2020-10-29T15:02:46.36176413+01:00", + "status": "fail" + }, + { + "debug": {}, + "end_time": "2020-10-29T15:02:46.361771463+01:00", + "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", + "name": "Firmware up-to-date", + "results": {}, + "start_time": "2020-10-29T15:02:46.361769463+01:00", + "status": "fail" + } + ], + "disruptive": false, + "inputs": null, + "name": "Commissioning" + }, + "grid_reconnection_time_seconds": 0, + "grid_state": "Grid_Uncompliant", + "type": "acpw", + "under_phase_detection": false, + "update_diagnostic": { + "category": "InternalComms", + "checks": [ + { + "debug": null, + "end_time": null, + "name": "Powerwall firmware", + "progress": 0, + "results": null, + "start_time": null, + "status": "not_run" + }, + { + "debug": null, + "end_time": null, + "name": "Battery firmware", + "progress": 0, + "results": null, + "start_time": null, + "status": "not_run" + }, + { + "debug": null, + "end_time": null, + "name": "Inverter firmware", + "progress": 0, + "results": null, + "start_time": null, + "status": "not_run" + }, + { + "debug": null, + "end_time": null, + "name": "Grid code", + "progress": 0, + "results": null, + "start_time": null, + "status": "not_run" + } + ], + "disruptive": true, + "inputs": null, + "name": "Firmware Update" + }, + "updating": false + } + ], + "running_phase_detection": false, + "states": [], + "sync": null, + "updating": false +} diff --git a/tests/unit/fixtures/site_info.json b/tests/unit/fixtures/site_info.json index 9cb2b3c..bb9e157 100644 --- a/tests/unit/fixtures/site_info.json +++ b/tests/unit/fixtures/site_info.json @@ -1 +1,22 @@ -{"max_system_energy_kWh": 0, "max_system_power_kW": 0, "site_name": "test", "timezone": "Europe/Berlin", "max_site_meter_power_kW": 1000000000, "min_site_meter_power_kW": -1000000000, "nominal_system_energy_kWh": 27, "nominal_system_power_kW": 10, "grid_code": {"grid_code": "test_grid_code", "grid_voltage_setting": 230, "grid_freq_setting": 50, "grid_phase_setting": "Single", "country": "Germany", "state": "*", "distributor": "*", "utility": "*", "retailer": "*", "region": "test_grid_code_region"}} \ No newline at end of file +{ + "grid_code": { + "country": "Germany", + "distributor": "*", + "grid_code": "test_grid_code", + "grid_freq_setting": 50, + "grid_phase_setting": "Single", + "grid_voltage_setting": 230, + "region": "test_grid_code_region", + "retailer": "*", + "state": "*", + "utility": "*" + }, + "max_site_meter_power_kW": 1000000000, + "max_system_energy_kWh": 0, + "max_system_power_kW": 0, + "min_site_meter_power_kW": -1000000000, + "nominal_system_energy_kWh": 27, + "nominal_system_power_kW": 10, + "site_name": "test", + "timezone": "Europe/Berlin" +} diff --git a/tests/unit/fixtures/sitemaster.json b/tests/unit/fixtures/sitemaster.json index 795c077..90478da 100644 --- a/tests/unit/fixtures/sitemaster.json +++ b/tests/unit/fixtures/sitemaster.json @@ -1 +1,6 @@ -{"status": "StatusUp", "running": true, "connected_to_tesla": true, "power_supply_mode": false} \ No newline at end of file +{ + "connected_to_tesla": true, + "power_supply_mode": false, + "running": true, + "status": "StatusUp" +} diff --git a/tests/unit/fixtures/status.json b/tests/unit/fixtures/status.json index c018a20..08a2d0a 100644 --- a/tests/unit/fixtures/status.json +++ b/tests/unit/fixtures/status.json @@ -1 +1,10 @@ -{"start_time": "2020-10-28 20:14:11 +0800", "up_time_seconds": "17h11m31.214751424s", "is_new": false, "version": "1.50.1 c58c2df3", "git_hash": "d0e69bde519634961cca04a616d2d4dae80b9f61", "commission_count": 0, "device_type": "hec", "sync_type": "v1"} \ No newline at end of file +{ + "commission_count": 0, + "device_type": "hec", + "git_hash": "d0e69bde519634961cca04a616d2d4dae80b9f61", + "is_new": false, + "start_time": "2020-10-28 20:14:11 +0800", + "sync_type": "v1", + "up_time_seconds": "17h11m31.214751424s", + "version": "1.50.1 c58c2df3" +} diff --git a/tests/unit/fixtures/system_status.json b/tests/unit/fixtures/system_status.json index 58c94ae..7a1ed61 100644 --- a/tests/unit/fixtures/system_status.json +++ b/tests/unit/fixtures/system_status.json @@ -1 +1,121 @@ -{"command_source": "Configuration", "battery_target_power": -3646.2544361664613, "battery_target_reactive_power": 0, "nominal_full_pack_energy": 28078, "nominal_energy_remaining": 14807, "max_power_energy_remaining": 0, "max_power_energy_to_be_charged": 0, "max_charge_power": 9200, "max_discharge_power": 9200, "max_apparent_power": 9200.000000000002, "instantaneous_max_discharge_power": 0, "instantaneous_max_charge_power": 0, "grid_services_power": 0, "system_island_state": "SystemGridConnected", "available_blocks": 2, "battery_blocks": [{"Type": "", "PackagePartNumber": "XXX-G", "PackageSerialNumber": "TGXXX", "disabled_reasons": [], "pinv_state": "PINV_GridFollowing", "pinv_grid_state": "Grid_Compliant", "nominal_energy_remaining": 7378, "nominal_full_pack_energy": 14031, "p_out": -1830, "q_out": 30, "v_out": 226.60000000000002, "f_out": 50.067, "i_out": 39, "energy_charged": 5525740, "energy_discharged": 4659550, "off_grid": false, "vf_mode": false, "wobble_detected": false, "charge_power_clamped": false, "backup_ready": true, "OpSeqState": "Active", "version": "67f943cb05d12d"}, {"Type": "", "PackagePartNumber": "XXX-G", "PackageSerialNumber": "TGXXX", "disabled_reasons": [], "pinv_state": "PINV_GridFollowing", "pinv_grid_state": "Grid_Compliant", "nominal_energy_remaining": 7429, "nominal_full_pack_energy": 14047, "p_out": -1830, "q_out": 30, "v_out": 230, "f_out": 50.068, "i_out": 39.2, "energy_charged": 5547410, "energy_discharged": 4677070, "off_grid": false, "vf_mode": false, "wobble_detected": false, "charge_power_clamped": false, "backup_ready": true, "OpSeqState": "Active", "version": "67f943cb05d12d"}], "ffr_power_availability_high": 9200, "ffr_power_availability_low": 9200, "load_charge_constraint": 0, "max_sustained_ramp_rate": 2500000, "grid_faults": [{"timestamp": 1634015591828, "alert_name": "PINV_a008_vfCheckRocof", "alert_is_fault": false, "decoded_alert": "[{\"name\":\"PINV_alertID\",\"value\":\"PINV_a008_vfCheckRocof\"},{\"name\":\"PINV_alertType\",\"value\":\"Warning\"}]", "alert_raw": 576460752303423488, "git_hash": "67f943cb05d12d", "site_uid": "TG-XXX", "ecu_type": "TEPINV", "ecu_package_part_number": "XXX-J", "ecu_package_serial_number": "TXXX"}, {"timestamp": 1634015591733, "alert_name": "PINV_a007_vfCheckOverFrequency", "alert_is_fault": false, "decoded_alert": "[{\"name\":\"PINV_alertID\",\"value\":\"PINV_a007_vfCheckOverFrequency\"},{\"name\":\"PINV_alertType\",\"value\":\"Warning\"},{\"name\":\"PINV_a007_frequency\",\"value\":52.189,\"units\":\"Hz\"}]", "alert_raw": 504575983454519296, "git_hash": "67f943cb05d12d", "site_uid": "XXX-uid", "ecu_type": "TEPINV", "ecu_package_part_number": "XXX-J", "ecu_package_serial_number": "TXXX"}, {"timestamp": 1634015591646, "alert_name": "PINV_a004_vfCheckUnderVoltage", "alert_is_fault": false, "decoded_alert": "[{\"name\":\"PINV_alertID\",\"value\":\"PINV_a004_vfCheckUnderVoltage\"},{\"name\":\"PINV_alertType\",\"value\":\"Warning\"},{\"name\":\"PINV_a004_uv_amplitude\",\"value\":123,\"units\":\"Vrms\"}]", "alert_raw": 288365616081928192, "git_hash": "67f943cb05d12d", "site_uid": "TG-XXX", "ecu_type": "TEPINV", "ecu_package_part_number": "1081100-79-J", "ecu_package_serial_number": "TXXX"}], "can_reboot": "Power flow is too high", "smart_inv_delta_p": 0, "smart_inv_delta_q": 0, "last_toggle_timestamp": "2021-09-30T18:11:41.110543639+02:00", "solar_real_power_limit": -1, "score": 10000, "blocks_controlled": 2, "primary": true, "auxiliary_load": 0, "all_enable_lines_high": true, "inverter_nominal_usable_power": 9200, "expected_energy_remaining": 0} +{ + "all_enable_lines_high": true, + "auxiliary_load": 0, + "available_blocks": 2, + "battery_blocks": [ + { + "OpSeqState": "Active", + "PackagePartNumber": "XXX-G", + "PackageSerialNumber": "TGXXX", + "Type": "", + "backup_ready": true, + "charge_power_clamped": false, + "disabled_reasons": [], + "energy_charged": 5525740, + "energy_discharged": 4659550, + "f_out": 50.067, + "i_out": 39, + "nominal_energy_remaining": 7378, + "nominal_full_pack_energy": 14031, + "off_grid": false, + "p_out": -1830, + "pinv_grid_state": "Grid_Compliant", + "pinv_state": "PINV_GridFollowing", + "q_out": 30, + "v_out": 226.60000000000002, + "version": "67f943cb05d12d", + "vf_mode": false, + "wobble_detected": false + }, + { + "OpSeqState": "Active", + "PackagePartNumber": "XXX-G", + "PackageSerialNumber": "TGXXX", + "Type": "", + "backup_ready": true, + "charge_power_clamped": false, + "disabled_reasons": [], + "energy_charged": 5547410, + "energy_discharged": 4677070, + "f_out": 50.068, + "i_out": 39.2, + "nominal_energy_remaining": 7429, + "nominal_full_pack_energy": 14047, + "off_grid": false, + "p_out": -1830, + "pinv_grid_state": "Grid_Compliant", + "pinv_state": "PINV_GridFollowing", + "q_out": 30, + "v_out": 230, + "version": "67f943cb05d12d", + "vf_mode": false, + "wobble_detected": false + } + ], + "battery_target_power": -3646.2544361664613, + "battery_target_reactive_power": 0, + "blocks_controlled": 2, + "can_reboot": "Power flow is too high", + "command_source": "Configuration", + "expected_energy_remaining": 0, + "ffr_power_availability_high": 9200, + "ffr_power_availability_low": 9200, + "grid_faults": [ + { + "alert_is_fault": false, + "alert_name": "PINV_a008_vfCheckRocof", + "alert_raw": 576460752303423488, + "decoded_alert": "[{\"name\":\"PINV_alertID\",\"value\":\"PINV_a008_vfCheckRocof\"},{\"name\":\"PINV_alertType\",\"value\":\"Warning\"}]", + "ecu_package_part_number": "XXX-J", + "ecu_package_serial_number": "TXXX", + "ecu_type": "TEPINV", + "git_hash": "67f943cb05d12d", + "site_uid": "TG-XXX", + "timestamp": 1634015591828 + }, + { + "alert_is_fault": false, + "alert_name": "PINV_a007_vfCheckOverFrequency", + "alert_raw": 504575983454519296, + "decoded_alert": "[{\"name\":\"PINV_alertID\",\"value\":\"PINV_a007_vfCheckOverFrequency\"},{\"name\":\"PINV_alertType\",\"value\":\"Warning\"},{\"name\":\"PINV_a007_frequency\",\"value\":52.189,\"units\":\"Hz\"}]", + "ecu_package_part_number": "XXX-J", + "ecu_package_serial_number": "TXXX", + "ecu_type": "TEPINV", + "git_hash": "67f943cb05d12d", + "site_uid": "XXX-uid", + "timestamp": 1634015591733 + }, + { + "alert_is_fault": false, + "alert_name": "PINV_a004_vfCheckUnderVoltage", + "alert_raw": 288365616081928192, + "decoded_alert": "[{\"name\":\"PINV_alertID\",\"value\":\"PINV_a004_vfCheckUnderVoltage\"},{\"name\":\"PINV_alertType\",\"value\":\"Warning\"},{\"name\":\"PINV_a004_uv_amplitude\",\"value\":123,\"units\":\"Vrms\"}]", + "ecu_package_part_number": "1081100-79-J", + "ecu_package_serial_number": "TXXX", + "ecu_type": "TEPINV", + "git_hash": "67f943cb05d12d", + "site_uid": "TG-XXX", + "timestamp": 1634015591646 + } + ], + "grid_services_power": 0, + "instantaneous_max_charge_power": 0, + "instantaneous_max_discharge_power": 0, + "inverter_nominal_usable_power": 9200, + "last_toggle_timestamp": "2021-09-30T18:11:41.110543639+02:00", + "load_charge_constraint": 0, + "max_apparent_power": 9200.000000000002, + "max_charge_power": 9200, + "max_discharge_power": 9200, + "max_power_energy_remaining": 0, + "max_power_energy_to_be_charged": 0, + "max_sustained_ramp_rate": 2500000, + "nominal_energy_remaining": 14807, + "nominal_full_pack_energy": 28078, + "primary": true, + "score": 10000, + "smart_inv_delta_p": 0, + "smart_inv_delta_q": 0, + "solar_real_power_limit": -1, + "system_island_state": "SystemGridConnected" +} diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 70f1c91..3558cb9 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -5,7 +5,7 @@ import responses from responses import GET, Response, add -from tesla_powerwall import API, AccessDeniedError, APIError +from tesla_powerwall import API, AccessDeniedError, ApiError from tests.unit import ENDPOINT @@ -33,20 +33,20 @@ def test_process_response(self): self.api._process_response(res) res.status_code = 404 - with self.assertRaises(APIError): + with self.assertRaises(ApiError): self.api._process_response(res) res.status_code = 502 - with self.assertRaises(APIError): + with self.assertRaises(ApiError): self.api._process_response(res) res.status_code = 200 res._content = b'{"error": "test_error"}' - with self.assertRaises(APIError): + with self.assertRaises(ApiError): self.api._process_response(res) res._content = b'{invalid_json"' - with self.assertRaises(APIError): + with self.assertRaises(ApiError): self.api._process_response(res) res._content = b"{}" @@ -86,3 +86,14 @@ def test_is_authenticated(self): def test_url(self): self.assertEqual(self.api.url("test"), ENDPOINT + "test") + + @responses.activate + def test_logout(self): + add( + Response(GET, url=f"{ENDPOINT}logout"), + body="", + content_type="application/json", + ) + self.api._http_session.cookies.set("AuthCookie", "foo") + + self.api.logout() diff --git a/tests/unit/test_powerwall.py b/tests/unit/test_powerwall.py index 9b01977..381fe2d 100644 --- a/tests/unit/test_powerwall.py +++ b/tests/unit/test_powerwall.py @@ -9,13 +9,15 @@ DeviceType, GridStatus, IslandMode, - Meter, + MeterDetailsReadings, + MeterDetailsResponse, MeterNotAvailableError, - MetersAggregates, + MeterResponse, + MetersAggregatesResponse, MeterType, MissingAttributeError, Powerwall, - SiteMaster, + SiteMasterResponse, assert_attribute, convert_to_kw, ) @@ -23,6 +25,10 @@ from tests.unit import ( ENDPOINT, GRID_STATUS_RESPONSE, + ISLANDING_MODE_OFFGRID_RESPONSE, + ISLANDING_MODE_ONGRID_RESPONSE, + METER_SITE_RESPONSE, + METER_SOLAR_RESPONSE, METERS_AGGREGATES_RESPONSE, OPERATION_RESPONSE, POWERWALLS_RESPONSE, @@ -30,8 +36,6 @@ SITEMASTER_RESPONSE, STATUS_RESPONSE, SYSTEM_STATUS_RESPONSE, - ISLANDING_MODE_ONGRID_RESPONSE, - ISLANDING_MODE_OFFGRID_RESPONSE, ) @@ -46,7 +50,9 @@ def test_get_api(self): def test_get_charge(self): add( Response( - GET, url=f"{ENDPOINT}system_status/soe", json={"percentage": 53.123423} + GET, + url=f"{ENDPOINT}system_status/soe", + json={"percentage": 53.123423}, ) ) self.assertEqual(self.powerwall.get_charge(), 53.123423) @@ -55,12 +61,14 @@ def test_get_charge(self): def test_get_sitemaster(self): add( Response( - responses.GET, url=f"{ENDPOINT}sitemaster", json=SITEMASTER_RESPONSE + responses.GET, + url=f"{ENDPOINT}sitemaster", + json=SITEMASTER_RESPONSE, ) ) sitemaster = self.powerwall.get_sitemaster() - self.assertIsInstance(sitemaster, SiteMaster) + self.assertIsInstance(sitemaster, SiteMasterResponse) self.assertEqual(sitemaster.status, "StatusUp") self.assertEqual(sitemaster.is_running, True) @@ -77,16 +85,63 @@ def test_get_meters(self): ) ) meters = self.powerwall.get_meters() - self.assertIsInstance(meters, MetersAggregates) + self.assertIsInstance(meters, MetersAggregatesResponse) self.assertListEqual( - meters.meters, - [MeterType.SITE, MeterType.BATTERY, MeterType.LOAD, MeterType.SOLAR], + list(meters.meters.keys()), + [ + MeterType.BATTERY, + MeterType.LOAD, + MeterType.SITE, + MeterType.SOLAR, + ], ) - self.assertIsInstance(meters.load, Meter) - self.assertIsInstance(meters.get_meter(MeterType.LOAD), Meter) + self.assertIsInstance(meters.load, MeterResponse) + self.assertIsInstance(meters.get_meter(MeterType.LOAD), MeterResponse) + self.assertIsNone(meters.get_meter(MeterType.GENERATOR)) with self.assertRaises(MeterNotAvailableError): meters.generator + @responses.activate + def test_get_meter_site(self): + add( + Response( + responses.GET, + url=f"{ENDPOINT}meters/site", + json=METER_SITE_RESPONSE, + ) + ) + meter = self.powerwall.get_meter_site() + self.assertIsInstance(meter, MeterDetailsResponse) + self.assertEqual(meter.location, MeterType.SITE) + readings = meter.readings + self.assertIsInstance(readings, MeterDetailsReadings) + # Optional voltage fields + self.assertIsInstance(readings.v_l1n, float) + self.assertIsInstance(readings.v_l2n, float) + self.assertIsNone(readings.v_l3n) + + self.assertEqual(readings.instant_power, -18.00000076368451) + self.assertEqual(readings.get_power(), -0.0) + + @responses.activate + def test_get_meter_solar(self): + add( + Response( + responses.GET, + url=f"{ENDPOINT}meters/solar", + json=METER_SOLAR_RESPONSE, + ) + ) + meter = self.powerwall.get_meter_solar() + self.assertIsInstance(meter, MeterDetailsResponse) + self.assertEqual(meter.location, MeterType.SOLAR) + readings = meter.readings + self.assertIsInstance(readings, MeterDetailsReadings) + # Optional voltage fields + self.assertIsInstance(readings.v_l1n, float) + self.assertIsNone(readings.v_l2n) + self.assertIsNone(readings.v_l3n) + @responses.activate def test_is_sending(self): add( @@ -131,7 +186,11 @@ def test_is_grid_services_active(self): @responses.activate def test_get_site_info(self): add( - Response(responses.GET, url=f"{ENDPOINT}site_info", json=SITE_INFO_RESPONSE) + Response( + responses.GET, + url=f"{ENDPOINT}site_info", + json=SITE_INFO_RESPONSE, + ) ) site_info = self.powerwall.get_site_info() self.assertEqual(site_info.nominal_system_energy, 27) @@ -172,7 +231,9 @@ def test_get_device_type(self): def test_get_serial_numbers(self): add( Response( - responses.GET, url=f"{ENDPOINT}powerwalls", json=POWERWALLS_RESPONSE + responses.GET, + url=f"{ENDPOINT}powerwalls", + json=POWERWALLS_RESPONSE, ) ) serial_numbers = self.powerwall.get_serial_numbers() @@ -182,7 +243,9 @@ def test_get_serial_numbers(self): def test_get_gateway_din(self): add( Response( - responses.GET, url=f"{ENDPOINT}powerwalls", json=POWERWALLS_RESPONSE + responses.GET, + url=f"{ENDPOINT}powerwalls", + json=POWERWALLS_RESPONSE, ) ) gateway_din = self.powerwall.get_gateway_din() @@ -191,7 +254,11 @@ def test_get_gateway_din(self): @responses.activate def test_get_backup_reserved_percentage(self): add( - Response(responses.GET, url=f"{ENDPOINT}operation", json=OPERATION_RESPONSE) + Response( + responses.GET, + url=f"{ENDPOINT}operation", + json=OPERATION_RESPONSE, + ) ) self.assertEqual( self.powerwall.get_backup_reserve_percentage(), 5.000019999999999 @@ -200,7 +267,11 @@ def test_get_backup_reserved_percentage(self): @responses.activate def test_get_operation_mode(self): add( - Response(responses.GET, url=f"{ENDPOINT}operation", json=OPERATION_RESPONSE) + Response( + responses.GET, + url=f"{ENDPOINT}operation", + json=OPERATION_RESPONSE, + ) ) self.assertEqual( self.powerwall.get_operation_mode(), OperationMode.SELF_CONSUMPTION @@ -242,7 +313,7 @@ def test_islanding_mode_offgrid(self): json=ISLANDING_MODE_OFFGRID_RESPONSE, ) ) - + mode = self.powerwall.set_island_mode(IslandMode.OFFGRID) self.assertEqual(mode, IslandMode.OFFGRID) @@ -255,7 +326,7 @@ def test_islanding_mode_ongrid(self): json=ISLANDING_MODE_ONGRID_RESPONSE, ) ) - + mode = self.powerwall.set_island_mode(IslandMode.ONGRID) self.assertEqual(mode, IslandMode.ONGRID) diff --git a/tox.ini b/tox.ini index 14e1256..f1ddd94 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,8 @@ [tox] envlist = testenv -isolated_build = True [testenv] -setenv = PYTHONPATH = {toxinidir} -deps = responses - packaging +deps = responses commands = python -m unittest discover {posargs:tests/unit} [testenv:unit] @@ -13,4 +10,4 @@ commands = python -m unittest discover tests/unit [testenv:integration] passenv = POWERWALL_IP POWERWALL_PASSWORD -commands = python -m unittest discover tests/integration \ No newline at end of file +commands = python -m unittest discover tests/integration