pax_global_header00006660000000000000000000000064145512613020014511gustar00rootroot0000000000000052 comment=a643f31a20e342ce1a35988cd440df16be3ee2c2 jrester-tesla_powerwall-a76f10b/000077500000000000000000000000001455126130200170015ustar00rootroot00000000000000jrester-tesla_powerwall-a76f10b/.envrc000066400000000000000000000000161455126130200201140ustar00rootroot00000000000000layout python jrester-tesla_powerwall-a76f10b/.github/000077500000000000000000000000001455126130200203415ustar00rootroot00000000000000jrester-tesla_powerwall-a76f10b/.github/workflows/000077500000000000000000000000001455126130200223765ustar00rootroot00000000000000jrester-tesla_powerwall-a76f10b/.github/workflows/python-publish.yml000066400000000000000000000012201455126130200261010ustar00rootroot00000000000000name: Publish Package on: release: types: - created - published - released jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v2 with: python-version: '3.9' - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel twine - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | python setup.py sdist bdist_wheel twine upload dist/* jrester-tesla_powerwall-a76f10b/.github/workflows/release.yml000066400000000000000000000012561455126130200245450ustar00rootroot00000000000000on: push: # Sequence of patterns matched against refs/tags tags: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 name: Create Release jobs: build: name: Create Release runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v2 - name: Create Release id: create_release uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token with: tag_name: ${{ github.ref }} release_name: ${{ github.ref }} draft: false prerelease: false jrester-tesla_powerwall-a76f10b/.gitignore000066400000000000000000000033251455126130200207740ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don’t work, or not # install all needed dependencies. #Pipfile.lock # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ .js .vscode .direnv # MacOS finder stuff .DS_Store jrester-tesla_powerwall-a76f10b/.pre-commit-config.yaml000066400000000000000000000014171455126130200232650ustar00rootroot00000000000000repos: - 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] jrester-tesla_powerwall-a76f10b/CHANGELOG000066400000000000000000000041421455126130200202140ustar00rootroot00000000000000# Changelog ## [0.5.1] - Use orjson for parsing json (https://github.com/jrester/tesla_powerwall/pull/59) - Expose low level information for each battery pack (https://github.com/jrester/tesla_powerwall/pull/60) ## [0.5.0] - BREAKING: The API is now async by default (by @bubonicbob) ## [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) ## [0.3.18] - updated examples - add Metertype `busway` thanks to @maikukun (https://github.com/jrester/tesla_powerwall/pull/40) ## [0.3.17] - move `py.typed` to correct location (https://github.com/jrester/tesla_powerwall/pull/35) ## [0.3.16] - add `py.typed` file - remove all the version pinning and drop support for powerwall version < 0.47.0 - add more type hints - fix 'login_time' attribute in `LoginResponse` ## [0.3.15] - fix version pin when there is a sha trailer (https://github.com/jrester/tesla_powerwall/pull/34) - Add support for fetching the gateway_din (https://github.com/jrester/tesla_powerwall/pull/33) ## [0.3.14] - 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` ## [0.3.13] Implement `system_status` endpoint (https://github.com/jrester/tesla_powerwall/issues/31): - add `Battery` response type, which is returned by `get_batteries` - add `get_energy`, `get_capacity`, `get_batteries` ## [0.3.12] - add MeterType `generator` (https://github.com/jrester/tesla_powerwall/issues/30) ## [0.3.11] - meters of `MetersAggregates` can now only be accessed via `get_meter` (https://github.com/home-assistant/core/issues/56660) jrester-tesla_powerwall-a76f10b/LICENSE000066400000000000000000000020501455126130200200030ustar00rootroot00000000000000MIT License Copyright (c) 2024 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 in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 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. jrester-tesla_powerwall-a76f10b/README.md000066400000000000000000000270151455126130200202650ustar00rootroot00000000000000![Licence](https://img.shields.io/github/license/jrester/tesla_powerwall?style=for-the-badge) ![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. > 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) - [General](#general) - [Errors](#errors) - [Response](#response) - [Battery level](#battery-level) - [Capacity](#capacity) - [Battery Packs](#battery-packs) - [Powerwall Status](#powerwall-status) - [Sitemaster](#sitemaster) - [Siteinfo](#siteinfo) - [Meters](#meters) - [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) - [Powerwalls Serial Numbers](#powerwalls-serial-numbers) - [Gateway DIN](#gateway-din) - [VIN](#vin) - [Off-grid status](#off-grid-status-set-island-mode) - [Development](#development) ## Installation Install the library via pip: ```bash $ 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`: ```bash $ export POWERWALL_IP= $ export POWERWALL_PASSWORD= $ python3 examples/example.py ``` ### Setup ```python from tesla_powerwall import Powerwall # Create a simple powerwall object by providing the IP powerwall = Powerwall("") #=> # Create a powerwall object with more options powerwall = Powerwall( endpoint="", # Configure timeout; default is 10 timeout=10, # 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, disable_insecure_warning=True ) #=> ``` > Note: By default the API client does not verify the SSL certificate of the Powerwall. If you want to verify the SSL certificate you can set `verify_ssl` to `True`. > The API client suppresses warnings about an inseucre request (because we aren't verifing the certificate). If you want to enable those warnings you can set `disable_insecure_warning` to `False`. ### 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 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: ```python from tesla_powerwall import User # Login as customer without email # The default value for the email is "" await powerwall.login("") #=> # Login as customer with email await powerwall.login("", "") #=> # Login with different user await powerwall.login_as(User.INSTALLER, "", "") #=> # Check if we are logged in # This method only checks wether a cookie with a Bearer token exists # It does not verify whether this token is valid powerwall.is_authenticated() #=> True # Logout await powerwall.logout() powerwall.is_authenticated() #=> False ``` ### General The API object directly maps the REST endpoints with a python method in the form of `_`. So if you need the raw json responses you can use the API object. It can be either created manually or retrived from an existing `Powerwall`: ```python from tesla_powerwall import API # Manually create API object api = API('https:///') # Perform get on 'system_status/soe' await api.get_system_status_soe() #=> {'percentage': 97.59281925744594} # From existing powerwall api = powerwall.get_api() await api.get_system_status_soe() ``` The `Powerwall` objet provides a wrapper around the API and exposes common methods. ### Battery level Get charge in percent: ```python await powerwall.get_charge() #=> 97.59281925744594 (%) ``` Get charge in watt: ```python await powerwall.get_energy() #=> 14807 (Wh) ``` ### Capacity Get the capacity of your powerwall in watt: ```python await powerwall.get_capacity() #=> 28078 (Wh) ``` ### Battery Packs Get information about the battery packs that are installed: ```python batteries = await powerwall.get_batteries() #=> [, ] batteries[0].part_number #=> "XXX-G" batteries[0].serial_number #=> "TGXXX" batteries[0].energy_remaining #=> 7378 (Wh) batteries[0].capacity #=> 14031 (Wh) batteries[0].energy_charged #=> 5525740 (Wh) batteries[0].energy_discharged #=> 4659550 (Wh) batteries[0].wobble_detected #=> False batteries[0].p_out #=> 260 batteries[0].q_out #=> -1080 batteries[0].v_out #=> 245.70 batteries[0].f_out #=> 49.953 batteries[0].i_out #=> -7.4 batteries[0].grid_state #=> GridState.COMPLIANT ``` ### Powerwall Status ```python status = await powerwall.get_status() #=> status.version #=> '1.49.0' status.up_time_seconds #=> datetime.timedelta(days=13, seconds=63287, microseconds=146455) status.start_time #=> datetime.datetime(2020, 9, 23, 23, 31, 16, tzinfo=datetime.timezone(datetime.timedelta(seconds=28800))) status.device_type #=> DeviceType.GW2 ``` ### Sitemaster ```python sm = await powerwall.get_sitemaster() #=> sm.status #=> StatusUp sm.running #=> true sm.connected_to_tesla #=> true ``` The sitemaster can be started and stopped using `run()` and `stop()` ### Siteinfo ```python info = await powerwall.get_site_info() #=> info.site_name #=> 'Tesla Home' info.country #=> 'Germany' info.nominal_system_energy #=> 13.5 (kWh) info.timezone #=> 'Europe/Berlin' ``` ### Meters #### Aggregates ```python from tesla_powerwall import MeterType meters = await powerwall.get_meters() #=> # access meter, but may return None when meter is not available 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.keys() #=> [, , , ] ``` Available meters are: `solar`, `site`, `load`, `battery`, `generator`, and `busway`. Some of those meters might not be available based on the installation and raise MeterNotAvailableError when accessed. #### Current power supply/draw `Meter` provides different methods for checking current power supply/draw: ```python meters = await powerwall.get_meters() meters.solar.get_power() #=> 0.4 (kW) meters.solar.instant_power #=> 409.941801071167 (W) meters.solar.is_drawing_from() #=> True meters.load.is_sending_to() #=> True meters.battery.is_active() #=> False # Different precision settings might return different results meters.battery.is_active(precision=5) #=> True ``` > Note: For MeterType.LOAD `is_drawing_from` **always** returns `False` because it cannot be drawn from `load`. #### Energy exported/imported Get energy exported/imported in watt-hours (Wh) with `energy_exported` and `energy_imported`. For the values in kilowatt-hours (kWh) use `get_energy_exported` and `get_energy_imported`: ```python meters.battery.energy_exported #=> 6394100 (Wh) meters.battery.get_energy_exported() #=> 6394.1 (kWh) meters.battery.energy_imported #=> 7576570 (Wh) meters.battery.get_energy_imported() #=> 7576.6 (kWh) ``` ### Details You can receive more detailed information about the meters `site` and `solar`: ```python meter_details = await 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 await powerwall.get_device_type() #=> ``` ### Grid Status Get current grid status. ```python await powerwall.get_grid_status() #=> await powerwall.is_grid_services_active() #=> False ``` ### Operation mode ```python await powerwall.get_operation_mode() #=> await powerwall.get_backup_reserve_percentage() #=> 5.000019999999999 (%) ``` ### Powerwalls Serial Numbers ```python await serials = powerwall.get_serial_numbers() #=> ["...", "...", ...] ``` ### Gateway DIN ```python await din = powerwall.get_gateway_din() #=> 4159645-02-A--TGXXX ``` ### VIN ```python await 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. #### Set powerwall to off-grid (Islanded) ```python await powerwall.set_island_mode(IslandMode.OFFGRID) ``` #### Set powerwall to off-grid (Connected) ```python await powerwall.set_island_mode(IslandMode.ONGRID) ``` # Development ## pre-commit This project uses pre-commit to run linters, formatters and type checking. You can easily run those checks locally: ```sh # Install the pre-commit hooks $ pre-commit install pre-commit installed at .git/hooks/pre-commit ``` Now those checks will be execute on every `git commit`. You can also execute all checks manually with `pre-commit run --all-files`. ## Building ```sh $ python -m build ``` ## Testing The tests are split in unit and integration tests. The unit tests are self-contained and can simply be run locally by executing `tox -e unit`, whereas the integration test, run against a real powerwall. ### Unit-Tests To run unit tests use tox: ```sh $ tox -e unit ``` ### Integration-Tests To execute the integration tests you need to first provide some information about your powerwall: ```sh $ export POWERWALL_IP= $ export POWERWALL_PASSWORD= $ tox -e integration ``` > The integration tests might take your powerwall off grid and bring it back online. Before running the tests, make sure that you know what you are doing! jrester-tesla_powerwall-a76f10b/examples/000077500000000000000000000000001455126130200206175ustar00rootroot00000000000000jrester-tesla_powerwall-a76f10b/examples/example.py000066400000000000000000000031661455126130200226320ustar00rootroot00000000000000import os from tesla_powerwall import MeterResponse, Powerwall def getenv(var): val = os.getenv(var) if val is None: raise ValueError(f"{var} must be set") return val def print_meter_row(meter_data: MeterResponse): print( "{:>8} {:>8} {:>17} {:>17} {!r:>8} {!r:>17} {!r:>17}".format( meter_data.meter.value, meter_data.get_power(), meter_data.get_energy_exported(), meter_data.get_energy_imported(), meter_data.is_active(), meter_data.is_drawing_from(), meter_data.is_sending_to(), ) ) ip = getenv("POWERWALL_IP") password = getenv("POWERWALL_PASSWORD") power_wall = Powerwall(ip) power_wall.login(password) site_name = power_wall.get_site_info().site_name meters_agg = power_wall.get_meters() print(f"{site_name}:\n") row_format = "{:>18}: {}" values = [ ("Charge (%)", round(power_wall.get_charge())), ("Capacity", power_wall.get_capacity()), ("Nominal Energy", power_wall.get_energy()), ("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()), ] for val in values: print(row_format.format(*val)) print("\n") print( "{:>8} {:>8} {:>17} {:>17} {:>8} {:>17} {:>17}".format( "Meter", "Power", "Energy exported", "Energy imported", "Active", "Drawing from", "Sending to", ) ) for meter in meters_agg.meters.values(): print_meter_row(meter) jrester-tesla_powerwall-a76f10b/pyproject.toml000066400000000000000000000016061455126130200217200ustar00rootroot00000000000000[build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] name = "tesla_powerwall" version = "0.5.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 = [ "aiohttp>=3.7.4", "urllib3>=1.26.18", "orjson>=3.9.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 jrester-tesla_powerwall-a76f10b/tesla_powerwall/000077500000000000000000000000001455126130200222055ustar00rootroot00000000000000jrester-tesla_powerwall-a76f10b/tesla_powerwall/__init__.py000066400000000000000000000015231455126130200243170ustar00rootroot00000000000000# ruff: noqa: F401 from .api import API from .const import ( SUPPORTED_OPERATION_MODES, DeviceType, GridState, GridStatus, IslandMode, LineStatus, MeterType, OperationMode, Roles, SyncType, User, ) from .error import ( AccessDeniedError, ApiError, MeterNotAvailableError, MissingAttributeError, PowerwallError, PowerwallUnreachableError, ) from .helpers import assert_attribute, convert_to_kw from .powerwall import Powerwall from .responses import ( BatteryResponse, LoginResponse, MeterDetailsReadings, MeterDetailsResponse, MeterResponse, MetersAggregatesResponse, PowerwallStatusResponse, SiteInfoResponse, SiteMasterResponse, SolarResponse, ) VERSION = "0.5.0" __all__ = list(filter(lambda n: not n.startswith("_"), globals().keys())) jrester-tesla_powerwall-a76f10b/tesla_powerwall/api.py000066400000000000000000000222241455126130200233320ustar00rootroot00000000000000from http.client import responses from json.decoder import JSONDecodeError from types import TracebackType from typing import Any, List, Optional, Type from urllib.parse import urljoin import aiohttp import orjson from urllib3 import disable_warnings from urllib3.exceptions import InsecureRequestWarning from .error import AccessDeniedError, ApiError, PowerwallUnreachableError class API(object): def __init__( self, endpoint: str, timeout: int = 10, http_session: Optional[aiohttp.ClientSession] = None, verify_ssl: bool = False, disable_insecure_warning: bool = True, ) -> None: if disable_insecure_warning: disable_warnings(InsecureRequestWarning) self._endpoint = self._parse_endpoint(endpoint) self._timeout = aiohttp.ClientTimeout(total=timeout) self._owns_http_session = False if http_session else True self._ssl = None if verify_ssl else False if http_session: self._owns_http_session = False self._http_session = http_session else: self._owns_http_session = True # Allow unsafe cookies so that folks can use IP addresses in their configs # See: https://docs.aiohttp.org/en/v3.7.3/client_advanced.html#cookie-safety jar = aiohttp.CookieJar(unsafe=True) self._http_session = aiohttp.ClientSession(cookie_jar=jar) @staticmethod def _parse_endpoint(endpoint: str) -> str: if endpoint.startswith("https"): endpoint = endpoint elif endpoint.startswith("http"): endpoint = endpoint.replace("http", "https") else: # Use str.format instead of f'strings to be backwards compatible endpoint = "https://{}".format(endpoint) if not endpoint.endswith("api") and not endpoint.endswith("/"): endpoint += "/api/" elif endpoint.endswith("api"): endpoint += "/" elif endpoint.endswith("/") and not endpoint.endswith("api/"): endpoint += "api/" return endpoint @staticmethod async def _handle_error(response: aiohttp.ClientResponse) -> None: if response.status == 404: raise ApiError( "The url {} returned error 404".format(str(response.real_url)) ) if response.status == 401 or response.status == 403: response_json = None try: response_json = await response.json(loads=orjson.loads) except Exception: raise AccessDeniedError(str(response.real_url)) else: raise AccessDeniedError( str(response.real_url), response_json.get("error"), response_json.get("message"), ) response_text = await response.text() if response_text is not None and len(response_text) > 0: raise ApiError( "API returned status code '{}: {}' with body: {}".format( response.status, responses.get(response.status), response_text, ) ) else: raise ApiError( "API returned status code '{}: {}' ".format( response.status, responses.get(response.status) ) ) async def _process_response(self, response: aiohttp.ClientResponse) -> dict: if response.status >= 400: # API returned some sort of error that must be handled await self._handle_error(response) content = await response.read() if len(content) == 0: return {} try: response_json = await response.json(content_type=None, loads=orjson.loads) except JSONDecodeError: raise ApiError( "Error while decoding json of response: {}".format(response.text) ) if response_json is None: return {} # 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"]) return response_json def url(self, path: str): return urljoin(self._endpoint, path) async def get(self, path: str, headers: dict = {}) -> Any: try: response = await self._http_session.get( url=self.url(path), timeout=self._timeout, headers=headers, ssl=self._ssl, ) except aiohttp.ClientConnectionError as e: raise PowerwallUnreachableError(str(e)) return await self._process_response(response) async def post( self, path: str, payload: dict, headers: dict = {}, ) -> Any: try: response = await self._http_session.post( url=self.url(path), json=payload, timeout=self._timeout, headers=headers, ssl=self._ssl, ) except aiohttp.ClientConnectionError as e: raise PowerwallUnreachableError(str(e)) return await self._process_response(response) def is_authenticated(self) -> bool: for cookie in self._http_session.cookie_jar: if "AuthCookie" == cookie.key: return True return False async def login( 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 await self.post( "login/Basic", { "username": username, "email": email, "password": password, "force_sm_off": force_sm_off, }, ) async def logout(self) -> None: if not self.is_authenticated(): raise ApiError("Must be logged in to log out") # The api unsets the auth cookie and the token is invalidated await self.get("logout") async def close(self) -> None: if self._owns_http_session: await self._http_session.close() async def __aenter__(self) -> "API": return self async def __aexit__( self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType], ) -> None: await self.close() # Endpoints are mapped to one method by _ so they can be easily accessed async def get_system_status(self) -> dict: return await self.get("system_status") async def get_system_status_soe(self) -> dict: return await self.get("system_status/soe") async def get_meters_aggregates(self) -> dict: return await self.get("meters/aggregates") async def get_sitemaster_run(self): return await self.get("sitemaster/run") async def get_sitemaster_stop(self): return await self.get("sitemaster/stop") async def get_sitemaster(self) -> dict: return await self.get("sitemaster") async def get_status(self) -> dict: return await self.get("status") async def get_customer_registration(self) -> dict: return await self.get("customer/registration") async def get_powerwalls(self): return await self.get("powerwalls") async def get_operation(self) -> dict: return await self.get("operation") async def get_networks(self) -> list: return await self.get("networks") async def get_phase_usage(self): return await self.get("powerwalls/phase_usages") async def post_sitemaster_run_for_commissioning(self): return await self.post("sitemaster/run_for_commissioning", payload={}) async def get_solars(self): return await self.get("solars") async def get_config(self): return await self.get("config") async def get_logs(self): return await self.get("getlogs") async def get_meters(self) -> list: return await self.get("meters") async def get_meters_site(self) -> list: return await self.get("meters/site") async def get_meters_solar(self) -> list: return await self.get("meters/solar") async def get_installer(self) -> dict: return await self.get("installer") async def get_solar_brands(self) -> List[str]: return await self.get("solars/brands") async def get_system_update_status(self) -> dict: return await self.get("system/update/status") async def get_system_status_grid_status(self) -> dict: return await self.get("system_status/grid_status") async def get_site_info(self) -> dict: return await self.get("site_info") async def get_site_info_grid_codes(self) -> list: return await self.get("site_info/grid_codes") async def post_site_info_site_name(self, body: dict) -> dict: return await self.post("site_info/site_name", body) async def post_islanding_mode(self, body: dict) -> dict: return await self.post("v2/islanding/mode", body) jrester-tesla_powerwall-a76f10b/tesla_powerwall/const.py000066400000000000000000000041511455126130200237060ustar00rootroot00000000000000from enum import Enum DEFAULT_KW_ROUND_PERSICION = 1 class User(Enum): INSTALLER = "installer" CUSTOMER = "customer" ENGINEER = "engineer" KIOSK = "kiosk" ADMIN = "admin" class Roles(Enum): HOME_OWNER = "Home_Owner" KIOSK_VIEWER = "Kiosk_Viewer" PROVIDER_ENGINEER = "Provider_Engineer" TESLA_ENGINEER = "Tesla_Engineer" class GridStatus(Enum): CONNECTED = "SystemGridConnected" ISLANDED_READY = "SystemIslandedReady" ISLANDED = "SystemIslandedActive" 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" UNCOMPLIANT = "Grid_Uncompliant" class LineStatus(Enum): NON_BACKUP = "NonBackup" BACKUP = "Backup" NOT_CONFIGURED = "NotConfigured" class OperationMode(Enum): BACKUP = "backup" SELF_CONSUMPTION = "self_consumption" AUTONOMOUS = "autonomous" SCHEDULER = "scheduler" SITE_CONTROL = "site_control" SUPPORTED_OPERATION_MODES = [ OperationMode.BACKUP, OperationMode.SELF_CONSUMPTION, OperationMode.AUTONOMOUS, ] class InterfaceType(Enum): ETH = "EthType" GSM = "GsmType" WIFI = "WifiType" class MeterType(Enum): SOLAR = "solar" SITE = "site" BATTERY = "battery" LOAD = "load" GENERATOR = "generator" BUSWAY = "busway" class DeviceType(Enum): """ Devicetype as returned by "device_type" GW1: Gateway 1 GW2: Gateway 2 SMC: ? """ GW1 = "hec" GW2 = "teg" SMC = "smc" class SyncType(Enum): V1 = "v1" V2 = "v2" V2_1 = "v2.1" class UpdateState(Enum): CHECKING = "/clear_update_status" SUCCEEDED = "/update_succeeded" FAILED = "/update_failed" STAGED = "/update_staged" DOWNLOAD = "/download" DOWNLOADED = "/update_downloaded" UNKNOWN = "/update_unknown" class UpdateStatus(Enum): IGNORING = "ignoring" ERROR = "error" NONACTIONABLE = "nonactionable" jrester-tesla_powerwall-a76f10b/tesla_powerwall/error.py000066400000000000000000000044351455126130200237160ustar00rootroot00000000000000from typing import List, Union from .const import MeterType class PowerwallError(Exception): def __init__(self, msg: str): super().__init__(msg) class ApiError(PowerwallError): def __init__(self, error: str): super().__init__("Powerwall api error: {}".format(error)) class MissingAttributeError(ApiError): def __init__(self, response: dict, attribute: str, url: Union[str, None] = None): self.response: dict = response self.attribute: str = attribute self.url: Union[str, None] = url if url is None: super().__init__( "The attribute '{}' is expected in the response but is missing.".format( attribute ) ) else: super().__init__( "The attribute '{}' is expected in the response for \ '{}' but is missing.".format( attribute, url ) ) class PowerwallUnreachableError(PowerwallError): def __init__(self, reason: Union[str, None] = None): msg = "Powerwall is unreachable" self.reason: Union[str, None] = reason if reason is not None: msg = "{}: {}".format(msg, reason) super().__init__(msg) class AccessDeniedError(PowerwallError): def __init__( self, resource: str, error: Union[str, None] = None, message: Union[str, None] = None, ): self.resource: str = resource self.error: Union[str, None] = error self.message: Union[str, None] = message msg = "Access denied for resource {}".format(resource) if error is not None: if message is not None: msg = "{}: {}: {}".format(msg, error, message) else: msg = "{}: {}".format(msg, error) super().__init__(msg) class MeterNotAvailableError(PowerwallError): 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.value, available_meters ) ) jrester-tesla_powerwall-a76f10b/tesla_powerwall/helpers.py000066400000000000000000000010561455126130200242230ustar00rootroot00000000000000from typing import Union from .error import MissingAttributeError def convert_to_kw(value: float, precision: int = 1) -> float: """Converts watt to kilowatt and rounds to precision""" # Don't round if precision is -1 if precision == -1: return value / 1000 else: return round(value / 1000, precision) def assert_attribute(response: dict, attr: str, url: Union[str, None] = None): value = response.get(attr) if value is None: raise MissingAttributeError(response, attr, url) else: return value jrester-tesla_powerwall-a76f10b/tesla_powerwall/powerwall.py000066400000000000000000000156611455126130200246040ustar00rootroot00000000000000from types import TracebackType from typing import List, Optional, Type, Union import aiohttp from .api import API from .const import DeviceType, GridStatus, IslandMode, OperationMode, User from .error import ApiError from .helpers import assert_attribute from .responses import ( BatteryResponse, LoginResponse, MeterDetailsResponse, MetersAggregatesResponse, PowerwallStatusResponse, SiteInfoResponse, SiteMasterResponse, SolarResponse, ) class Powerwall: def __init__( self, endpoint: str, timeout: int = 10, http_session: Union[aiohttp.ClientSession, None] = None, verify_ssl: bool = False, disable_insecure_warning: bool = True, ): self._api = API( endpoint=endpoint, timeout=timeout, http_session=http_session, verify_ssl=verify_ssl, disable_insecure_warning=disable_insecure_warning, ) async def login_as( self, user: Union[User, str], password: str, email: str, force_sm_off: bool = False, ) -> LoginResponse: if isinstance(user, User): user = user.value response = await self._api.login(user, email, password, force_sm_off) # The api returns an auth cookie which is automatically set # so there is no need to further process the response return LoginResponse.from_dict(response) async def login( self, password: str, email: str = "", force_sm_off: bool = False ) -> LoginResponse: return await self.login_as(User.CUSTOMER, password, email, force_sm_off) async def logout(self) -> None: await self._api.logout() def is_authenticated(self) -> bool: return self._api.is_authenticated() async def run(self) -> None: await self._api.get_sitemaster_run() async def stop(self) -> None: await self._api.get_sitemaster_stop() async def get_charge(self) -> Union[float, int]: return assert_attribute( await self._api.get_system_status_soe(), "percentage", "soe" ) async def get_energy(self) -> int: return assert_attribute( await self._api.get_system_status(), "nominal_energy_remaining", "system_status", ) async def get_sitemaster(self) -> SiteMasterResponse: return SiteMasterResponse.from_dict(await self._api.get_sitemaster()) async def get_meters(self) -> MetersAggregatesResponse: return MetersAggregatesResponse.from_dict( await self._api.get_meters_aggregates() ) async def get_meter_site(self) -> MeterDetailsResponse: meter_response = await 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]) async def get_meter_solar(self) -> MeterDetailsResponse: meter_response = await 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") return MeterDetailsResponse.from_dict(meter_response[0]) async def get_grid_status(self) -> GridStatus: """Returns the current grid status.""" status = assert_attribute( await self._api.get_system_status_grid_status(), "grid_status", "grid_status", ) return GridStatus(status) async def get_capacity(self) -> float: return assert_attribute( await self._api.get_system_status(), "nominal_full_pack_energy", "system_status", ) async def get_batteries(self) -> List[BatteryResponse]: batteries = assert_attribute( await self._api.get_system_status(), "battery_blocks", "system_status" ) return [BatteryResponse.from_dict(battery) for battery in batteries] async def is_grid_services_active(self) -> bool: return assert_attribute( await self._api.get_system_status_grid_status(), "grid_services_active", "grid_status", ) async def get_site_info(self) -> SiteInfoResponse: """Returns information about the powerwall site""" return SiteInfoResponse.from_dict(await self._api.get_site_info()) async def set_site_name(self, site_name: str) -> dict: return await self._api.post_site_info_site_name({"site_name": site_name}) async def get_status(self) -> PowerwallStatusResponse: return PowerwallStatusResponse.from_dict(await self._api.get_status()) async def get_device_type(self) -> DeviceType: """Returns the device type of the powerwall""" return (await self.get_status()).device_type async def get_serial_numbers(self) -> List[str]: powerwalls = assert_attribute( await self._api.get_powerwalls(), "powerwalls", "powerwalls" ) return [ assert_attribute(powerwall, "PackageSerialNumber") for powerwall in powerwalls ] async def get_gateway_din(self) -> str: """Return the gateway din.""" return assert_attribute( await self._api.get_powerwalls(), "gateway_din", "powerwalls" ) async def get_operation_mode(self) -> OperationMode: operation_mode = assert_attribute( await self._api.get_operation(), "real_mode", "operation" ) return OperationMode(operation_mode) async def get_backup_reserve_percentage(self) -> float: return assert_attribute( await self._api.get_operation(), "backup_reserve_percent", "operation" ) async def get_solars(self) -> List[SolarResponse]: return [ SolarResponse.from_dict(solar) for solar in await self._api.get_solars() ] async def get_vin(self) -> str: return assert_attribute(await self._api.get_config(), "vin", "config") async def set_island_mode(self, mode: IslandMode) -> IslandMode: return IslandMode( assert_attribute( await self._api.post_islanding_mode({"island_mode": mode.value}), "island_mode", ) ) async def get_version(self) -> str: version_str = assert_attribute( await self._api.get_status(), "version", "status" ) return version_str.split(" ")[ 0 ] # newer versions include a sha trailer '21.44.1 c58c2df3' def get_api(self) -> API: return self._api async def close(self) -> None: await self._api.close() async def __aenter__(self) -> "Powerwall": return self async def __aexit__( self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType], ) -> None: await self.close() jrester-tesla_powerwall-a76f10b/tesla_powerwall/py.typed000066400000000000000000000000001455126130200236720ustar00rootroot00000000000000jrester-tesla_powerwall-a76f10b/tesla_powerwall/responses.py000066400000000000000000000221521455126130200246020ustar00rootroot00000000000000import re from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any, Dict, List, Optional from .const import ( DEFAULT_KW_ROUND_PERSICION, DeviceType, GridState, MeterType, Roles, ) from .error import MeterNotAvailableError from .helpers import convert_to_kw @dataclass class ResponseBase: _raw: dict def __repr__(self) -> str: 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) def get_energy_imported(self, precision=DEFAULT_KW_ROUND_PERSICION) -> float: return convert_to_kw(self.energy_imported, precision) def get_instant_total_current(self, precision=DEFAULT_KW_ROUND_PERSICION) -> float: return round(self.instant_total_current, precision) def get_power(self, precision=DEFAULT_KW_ROUND_PERSICION) -> float: return convert_to_kw(self.instant_power, precision) def is_active(self, precision=DEFAULT_KW_ROUND_PERSICION) -> bool: return self.get_power(precision) != 0 def is_drawing_from(self, precision=DEFAULT_KW_ROUND_PERSICION) -> bool: if self.meter == MeterType.LOAD: # Cannot draw from load return False else: return self.get_power(precision) > 0 def is_sending_to(self, precision=DEFAULT_KW_ROUND_PERSICION) -> bool: if self.meter == MeterType.LOAD: # For load the power is always positiv return self.get_power(precision) > 0 else: return self.get_power(precision) < 0 @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.meters[m] else: raise MeterNotAvailableError(m, list(self.meters.keys())) else: return object.__getattribute__(self, attr) 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)?$" ) @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(): if param: time_params[name] = float(param) return timedelta(**time_params) @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 p_out: int q_out: int v_out: float f_out: float i_out: float grid_state: GridState @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"], p_out=src["p_out"], q_out=src["q_out"], v_out=src["v_out"], f_out=src["f_out"], i_out=src["i_out"], grid_state=GridState(src["pinv_grid_state"]), ) jrester-tesla_powerwall-a76f10b/tests/000077500000000000000000000000001455126130200201435ustar00rootroot00000000000000jrester-tesla_powerwall-a76f10b/tests/__init__.py000066400000000000000000000000001455126130200222420ustar00rootroot00000000000000jrester-tesla_powerwall-a76f10b/tests/integration/000077500000000000000000000000001455126130200224665ustar00rootroot00000000000000jrester-tesla_powerwall-a76f10b/tests/integration/__init__.py000066400000000000000000000003001455126130200245700ustar00rootroot00000000000000import os ENV_POWERWALL_IP = "POWERWALL_IP" ENV_POWERWALL_PASSWORD = "POWERWALL_PASSWORD" POWERWALL_IP = os.environ[ENV_POWERWALL_IP] POWERWALL_PASSWORD = os.environ[ENV_POWERWALL_PASSWORD] jrester-tesla_powerwall-a76f10b/tests/integration/test_powerwall.py000066400000000000000000000117251455126130200261210ustar00rootroot00000000000000import asyncio import unittest from tesla_powerwall import GridStatus, IslandMode, MeterType, Powerwall from tesla_powerwall.responses import ( MeterResponse, MetersAggregatesResponse, PowerwallStatusResponse, SiteInfoResponse, SiteMasterResponse, ) from tests.integration import POWERWALL_IP, POWERWALL_PASSWORD class TestPowerwall(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.powerwall = Powerwall(POWERWALL_IP) await self.powerwall.login(POWERWALL_PASSWORD) assert self.powerwall.is_authenticated() async def asyncTearDown(self): await self.powerwall.close() await self.http_session.close() async def test_get_charge(self) -> None: charge = await self.powerwall.get_charge() if charge < 100: self.assertIsInstance(charge, float) else: self.assertEqual(charge, 100) async def test_get_meters(self) -> None: meters = await self.powerwall.get_meters() self.assertIsInstance(meters, MetersAggregatesResponse) 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.instant_average_voltage meter.get_energy_exported() meter.get_energy_imported() self.assertIsInstance(meter.get_power(), float) self.assertIsInstance(meter.is_active(), bool) self.assertIsInstance(meter.is_drawing_from(), bool) self.assertIsInstance(meter.is_sending_to(), bool) async def test_sitemaster(self) -> None: sitemaster = await self.powerwall.get_sitemaster() self.assertIsInstance(sitemaster, SiteMasterResponse) sitemaster.status sitemaster.is_running sitemaster.is_connected_to_tesla sitemaster.is_power_supply_mode async def test_site_info(self) -> None: site_info = await self.powerwall.get_site_info() self.assertIsInstance(site_info, SiteInfoResponse) site_info.nominal_system_energy site_info.site_name site_info.timezone async def test_capacity(self) -> None: self.assertIsInstance(await self.powerwall.get_capacity(), int) async def test_energy(self) -> None: self.assertIsInstance(await self.powerwall.get_energy(), int) async def test_batteries(self) -> None: batteries = await self.powerwall.get_batteries() self.assertGreater(len(batteries), 0) for battery in batteries: battery.wobble_detected battery.energy_discharged battery.energy_charged battery.energy_remaining battery.capacity battery.part_number battery.serial_number async def test_grid_status(self) -> None: grid_status = await self.powerwall.get_grid_status() self.assertIsInstance(grid_status, GridStatus) async def test_status(self) -> None: status = await self.powerwall.get_status() self.assertIsInstance(status, PowerwallStatusResponse) status.up_time_seconds status.start_time status.version async def test_islanding(self) -> None: initial_grid_status = await self.powerwall.get_grid_status() self.assertIsInstance(initial_grid_status, GridStatus) if initial_grid_status == GridStatus.CONNECTED: await self.go_offline() await self.go_online() elif initial_grid_status == GridStatus.ISLANDED: await self.go_offline() await self.go_online() async def go_offline(self) -> None: observedIslandMode = await self.powerwall.set_island_mode(IslandMode.OFFGRID) self.assertEqual(observedIslandMode, IslandMode.OFFGRID) await self.wait_until_grid_status(GridStatus.ISLANDED) self.assertEqual(await self.powerwall.get_grid_status(), GridStatus.ISLANDED) async def go_online(self) -> None: observedIslandMode = await self.powerwall.set_island_mode(IslandMode.ONGRID) self.assertEqual(observedIslandMode, IslandMode.ONGRID) await self.wait_until_grid_status(GridStatus.CONNECTED) self.assertEqual(await self.powerwall.get_grid_status(), GridStatus.CONNECTED) async def wait_until_grid_status( self, expectedStatus: GridStatus, sleepTime: int = 1, maxCycles: int = 20, ) -> None: cycles = 0 observedStatus: GridStatus while cycles < maxCycles: observedStatus = await self.powerwall.get_grid_status() if observedStatus == expectedStatus: break await asyncio.sleep(sleepTime) cycles = cycles + 1 self.assertEqual(observedStatus, expectedStatus) jrester-tesla_powerwall-a76f10b/tests/unit/000077500000000000000000000000001455126130200211225ustar00rootroot00000000000000jrester-tesla_powerwall-a76f10b/tests/unit/__init__.py000066400000000000000000000020411455126130200232300ustar00rootroot00000000000000import json from pathlib import Path ENDPOINT_SCHEME = "https://" ENDPOINT_HOST = "1.1.1.1" ENDPOINT_PATH = "/api/" ENDPOINT = f"{ENDPOINT_SCHEME}{ENDPOINT_HOST}{ENDPOINT_PATH}" FIXTURE_BASE_PATH = Path("tests/unit/fixtures") def load_fixture(name: str): path = FIXTURE_BASE_PATH / name with open(path) as f: return json.load(f) 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") jrester-tesla_powerwall-a76f10b/tests/unit/fixtures/000077500000000000000000000000001455126130200227735ustar00rootroot00000000000000jrester-tesla_powerwall-a76f10b/tests/unit/fixtures/grid_status.json000066400000000000000000000001141455126130200262120ustar00rootroot00000000000000{ "grid_services_active": false, "grid_status": "SystemGridConnected" } jrester-tesla_powerwall-a76f10b/tests/unit/fixtures/islanding_mode_offgrid.json000066400000000000000000000000661455126130200303440ustar00rootroot00000000000000{ "island_mode": "intentional_reconnect_failsafe" } jrester-tesla_powerwall-a76f10b/tests/unit/fixtures/islanding_mode_ongrid.json000066400000000000000000000000361455126130200302030ustar00rootroot00000000000000{ "island_mode": "backup" } jrester-tesla_powerwall-a76f10b/tests/unit/fixtures/meter_site.json000066400000000000000000000033601455126130200260300ustar00rootroot00000000000000[ { "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" } ] jrester-tesla_powerwall-a76f10b/tests/unit/fixtures/meter_solar.json000066400000000000000000000030401455126130200261770ustar00rootroot00000000000000[ { "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" } ] jrester-tesla_powerwall-a76f10b/tests/unit/fixtures/meters_aggregates.json000066400000000000000000000037561455126130200273710ustar00rootroot00000000000000{ "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 } } jrester-tesla_powerwall-a76f10b/tests/unit/fixtures/operation.json000066400000000000000000000002331455126130200256640ustar00rootroot00000000000000{ "backup_reserve_percent": 5.000019999999999, "freq_shift_load_shed_delta_f": 0, "freq_shift_load_shed_soe": 0, "real_mode": "self_consumption" } jrester-tesla_powerwall-a76f10b/tests/unit/fixtures/powerwalls.json000066400000000000000000000160721455126130200260730ustar00rootroot00000000000000{ "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 } jrester-tesla_powerwall-a76f10b/tests/unit/fixtures/site_info.json000066400000000000000000000010701455126130200256430ustar00rootroot00000000000000{ "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" } jrester-tesla_powerwall-a76f10b/tests/unit/fixtures/sitemaster.json000066400000000000000000000001521455126130200260440ustar00rootroot00000000000000{ "connected_to_tesla": true, "power_supply_mode": false, "running": true, "status": "StatusUp" } jrester-tesla_powerwall-a76f10b/tests/unit/fixtures/status.json000066400000000000000000000004171455126130200252130ustar00rootroot00000000000000{ "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" } jrester-tesla_powerwall-a76f10b/tests/unit/fixtures/system_status.json000066400000000000000000000100671455126130200266210ustar00rootroot00000000000000{ "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" } jrester-tesla_powerwall-a76f10b/tests/unit/test_api.py000066400000000000000000000163461455126130200233160ustar00rootroot00000000000000import json import unittest import aiohttp import aresponses from tesla_powerwall import API, AccessDeniedError, ApiError from tesla_powerwall.const import User from tests.unit import ENDPOINT, ENDPOINT_HOST, ENDPOINT_PATH class TestAPI(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.aresponses = aresponses.ResponsesMockServer() await self.aresponses.__aenter__() self.session = aiohttp.ClientSession() self.api = API(ENDPOINT, http_session=self.session) async def asyncTearDown(self): await self.api.close() await self.session.close() await self.aresponses.__aexit__(None, None, None) def test_parse_endpoint(self): test_endpoints = [ "1.1.1.1", "http://1.1.1.1", "https://1.1.1.1/api/", "https://1.1.1.1/api", "https://1.1.1.1/", ] for test_endpoint in test_endpoints: self.assertEqual(self.api._parse_endpoint(test_endpoint), ENDPOINT) async def test_process_response(self): status = 0 text = None def response_handler(request): return self.aresponses.Response(status=status, text=text) self.aresponses.add( ENDPOINT_HOST, f"{ENDPOINT_PATH}test", "GET", response_handler, repeat=self.aresponses.INFINITY, ) status = 401 async with self.session.get(f"{ENDPOINT}test") as response: with self.assertRaises(AccessDeniedError): await self.api._process_response(response) status = 404 async with self.session.get(f"{ENDPOINT}test") as response: with self.assertRaises(ApiError): await self.api._process_response(response) status = 502 async with self.session.get(f"{ENDPOINT}test") as response: with self.assertRaises(ApiError): await self.api._process_response(response) status = 200 text = '{"error": "test_error"}' async with self.session.get(f"{ENDPOINT}test") as response: with self.assertRaises(ApiError): await self.api._process_response(response) status = 200 text = '{invalid_json"' async with self.session.get(f"{ENDPOINT}test") as response: with self.assertRaises(ApiError): await self.api._process_response(response) status = 200 text = "{}" async with self.session.get(f"{ENDPOINT}test") as response: self.assertEqual(await self.api._process_response(response), {}) status = 200 text = '{"response": "ok"}' async with self.session.get(f"{ENDPOINT}test") as response: self.assertEqual( await self.api._process_response(response), {"response": "ok"} ) async def test_get(self): self.aresponses.add( ENDPOINT_HOST, f"{ENDPOINT_PATH}test_get", "GET", self.aresponses.Response(text='{"test_get": true}'), ) self.assertEqual(await self.api.get("test_get"), {"test_get": True}) self.aresponses.assert_plan_strictly_followed() async def test_post(self): self.aresponses.add( ENDPOINT_HOST, f"{ENDPOINT_PATH}test_post", "POST", self.aresponses.Response( text='{"test_post": true}', headers={"Content-Type": "application/json"} ), ) resp = await self.api.post("test_post", {"test": True}) self.assertIsInstance(resp, dict) self.assertEqual(resp, {"test_post": True}) self.aresponses.assert_plan_strictly_followed() async def test_is_authenticated(self): self.assertEqual(self.api.is_authenticated(), False) self.session.cookie_jar.update_cookies(cookies={"AuthCookie": "foo"}) self.assertEqual(self.api.is_authenticated(), True) def test_url(self): self.assertEqual(self.api.url("test"), ENDPOINT + "test") async def test_login(self): jar = aiohttp.CookieJar(unsafe=True) async with aiohttp.ClientSession(cookie_jar=jar) as http_session: async with API(ENDPOINT, http_session=http_session) as api: username = User.CUSTOMER.value password = "password" email = "email@email.com" async def response_handler(request) -> aresponses.Response: request_json = await request.json() self.assertEqual(request_json["username"], username) self.assertEqual(request_json["password"], password) self.assertEqual(request_json["email"], email) login_response = self.aresponses.Response( text=json.dumps( { "email": request_json["email"], "firstname": "Tesla", "lastname": "Energy", "roles": ["Home_Owner"], "token": "x4jbH...XMP8w==", "provider": "Basic", "loginTime": "2023-03-25T13:10:48.9029581+01:00", } ), headers={"Content-Type": "application/json"}, ) login_response.set_cookie("AuthCookie", "foo") return login_response self.aresponses.add( ENDPOINT_HOST, f"{ENDPOINT_PATH}login/Basic", "POST", response_handler, ) await api.login(username=username, email=email, password=password) self.aresponses.add( ENDPOINT_HOST, f"{ENDPOINT_PATH}logout", "GET", self.aresponses.Response( text="", headers={"Content-Type": "application/json"} ), ) await api.logout() self.aresponses.assert_plan_strictly_followed() async def test_close(self): api_session = None async with API(ENDPOINT) as api: api_session = api._http_session self.assertFalse(api_session.closed) self.assertTrue(api_session.closed) async with aiohttp.ClientSession() as session: async with API(ENDPOINT, http_session=session) as api: api_session = api._http_session self.assertFalse(api_session.closed) self.assertFalse(api_session.closed) self.assertTrue(api_session.closed) api = API(ENDPOINT) api_session = api._http_session self.assertFalse(api_session.closed) await api.close() self.assertTrue(api_session.closed) async with aiohttp.ClientSession() as session: api_session = session api = API(ENDPOINT, http_session=session) self.assertFalse(api_session.closed) await api.close() self.assertFalse(api_session.closed) self.assertTrue(api_session.closed) jrester-tesla_powerwall-a76f10b/tests/unit/test_powerwall.py000066400000000000000000000302641455126130200245540ustar00rootroot00000000000000import datetime import json import unittest from typing import Optional, Union import aiohttp import aresponses from tesla_powerwall import ( API, DeviceType, GridState, GridStatus, IslandMode, MeterDetailsReadings, MeterDetailsResponse, MeterNotAvailableError, MeterResponse, MetersAggregatesResponse, MeterType, MissingAttributeError, OperationMode, Powerwall, SiteMasterResponse, assert_attribute, convert_to_kw, ) from tests.unit import ( ENDPOINT, ENDPOINT_HOST, ENDPOINT_PATH, GRID_STATUS_RESPONSE, ISLANDING_MODE_OFFGRID_RESPONSE, ISLANDING_MODE_ONGRID_RESPONSE, METER_SITE_RESPONSE, METER_SOLAR_RESPONSE, METERS_AGGREGATES_RESPONSE, OPERATION_RESPONSE, POWERWALLS_RESPONSE, SITE_INFO_RESPONSE, SITEMASTER_RESPONSE, STATUS_RESPONSE, SYSTEM_STATUS_RESPONSE, ) class TestPowerWall(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.aresponses = aresponses.ResponsesMockServer() await self.aresponses.__aenter__() self.powerwall = Powerwall(ENDPOINT) async def asyncTearDown(self): await self.powerwall.close() await self.aresponses.__aexit__(None, None, None) def test_get_api(self): self.assertIsInstance(self.powerwall.get_api(), API) def add_response( self, path: str, method: str = "GET", content_type: str = "application/json", body: Optional[Union[str, dict]] = None, ): self.aresponses.add( ENDPOINT_HOST, f"{ENDPOINT_PATH}{path}", method, self.aresponses.Response( headers={"Content-Type": content_type}, text=json.dumps(body), ), ) async def test_get_charge(self): self.add_response("system_status/soe", body={"percentage": 53.123423}) self.assertEqual(await self.powerwall.get_charge(), 53.123423) self.aresponses.assert_plan_strictly_followed() async def test_get_sitemaster(self): self.add_response("sitemaster", body=SITEMASTER_RESPONSE) sitemaster = await self.powerwall.get_sitemaster() self.assertIsInstance(sitemaster, SiteMasterResponse) self.assertEqual(sitemaster.status, "StatusUp") self.assertEqual(sitemaster.is_running, True) self.assertEqual(sitemaster.is_connected_to_tesla, True) self.assertEqual(sitemaster.is_power_supply_mode, False) self.aresponses.assert_plan_strictly_followed() async def test_get_meters(self): self.add_response("meters/aggregates", body=METERS_AGGREGATES_RESPONSE) meters = await self.powerwall.get_meters() self.assertIsInstance(meters, MetersAggregatesResponse) self.assertListEqual( list(meters.meters.keys()), [ MeterType.BATTERY, MeterType.LOAD, MeterType.SITE, MeterType.SOLAR, ], ) 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 self.aresponses.assert_plan_strictly_followed() async def test_get_meter_site(self): self.add_response("meters/site", body=METER_SITE_RESPONSE) meter = await 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) self.aresponses.assert_plan_strictly_followed() async def test_get_meter_solar(self): self.add_response("meters/solar", body=METER_SOLAR_RESPONSE) meter = await 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) self.aresponses.assert_plan_strictly_followed() async def test_is_sending(self): self.add_response("meters/aggregates", body=METERS_AGGREGATES_RESPONSE) meters = await self.powerwall.get_meters() self.assertEqual(meters.get_meter(MeterType.SOLAR).is_sending_to(), False) self.assertEqual(meters.get_meter(MeterType.SOLAR).is_active(), True) self.assertEqual(meters.get_meter(MeterType.SOLAR).is_drawing_from(), True) self.assertEqual(meters.get_meter(MeterType.SITE).is_sending_to(), True) self.assertEqual(meters.get_meter(MeterType.LOAD).is_sending_to(), True) self.assertEqual(meters.get_meter(MeterType.LOAD).is_drawing_from(), False) self.assertEqual(meters.get_meter(MeterType.LOAD).is_active(), True) self.aresponses.assert_plan_strictly_followed() async def test_get_grid_status(self): self.add_response("system_status/grid_status", body=GRID_STATUS_RESPONSE) grid_status = await self.powerwall.get_grid_status() self.assertEqual(grid_status, GridStatus.CONNECTED) self.aresponses.assert_plan_strictly_followed() async def test_is_grid_services_active(self): self.add_response("system_status/grid_status", body=GRID_STATUS_RESPONSE) self.assertEqual(await self.powerwall.is_grid_services_active(), False) self.aresponses.assert_plan_strictly_followed() async def test_get_site_info(self): self.add_response("site_info", body=SITE_INFO_RESPONSE) site_info = await self.powerwall.get_site_info() self.assertEqual(site_info.nominal_system_energy, 27) self.assertEqual(site_info.site_name, "test") self.assertEqual(site_info.timezone, "Europe/Berlin") self.aresponses.assert_plan_strictly_followed() async def test_get_status(self): self.add_response("status", body=STATUS_RESPONSE) status = await self.powerwall.get_status() self.assertEqual( status.up_time_seconds, datetime.timedelta(seconds=61891, microseconds=214751), ) self.assertEqual( status.start_time, datetime.datetime( 2020, 10, 28, 20, 14, 11, tzinfo=datetime.timezone(datetime.timedelta(seconds=28800)), ), ) self.assertEqual(status.device_type, DeviceType.GW1) self.assertEqual(status.version, "1.50.1 c58c2df3") self.aresponses.assert_plan_strictly_followed() async def test_get_device_type(self): self.add_response("status", body=STATUS_RESPONSE) device_type = await self.powerwall.get_device_type() self.assertIsInstance(device_type, DeviceType) self.assertEqual(device_type, DeviceType.GW1) self.aresponses.assert_plan_strictly_followed() async def test_get_serial_numbers(self): self.add_response("powerwalls", body=POWERWALLS_RESPONSE) serial_numbers = await self.powerwall.get_serial_numbers() self.assertEqual(serial_numbers, ["SerialNumber1", "SerialNumber2"]) self.aresponses.assert_plan_strictly_followed() async def test_get_gateway_din(self): self.add_response("powerwalls", body=POWERWALLS_RESPONSE) gateway_din = await self.powerwall.get_gateway_din() self.assertEqual(gateway_din, "gateway_din") self.aresponses.assert_plan_strictly_followed() async def test_get_backup_reserved_percentage(self): self.add_response("operation", body=OPERATION_RESPONSE) self.assertEqual( await self.powerwall.get_backup_reserve_percentage(), 5.000019999999999 ) self.aresponses.assert_plan_strictly_followed() async def test_get_operation_mode(self): self.add_response("operation", body=OPERATION_RESPONSE) self.assertEqual( await self.powerwall.get_operation_mode(), OperationMode.SELF_CONSUMPTION ) self.aresponses.assert_plan_strictly_followed() async def test_get_version(self): self.add_response("status", body=STATUS_RESPONSE) self.assertEqual(await self.powerwall.get_version(), "1.50.1") self.aresponses.assert_plan_strictly_followed() async def test_system_status(self): self.add_response("system_status", body=SYSTEM_STATUS_RESPONSE) self.assertEqual(await self.powerwall.get_capacity(), 28078) self.add_response("system_status", body=SYSTEM_STATUS_RESPONSE) self.assertEqual(await self.powerwall.get_energy(), 14807) self.add_response("system_status", body=SYSTEM_STATUS_RESPONSE) batteries = await self.powerwall.get_batteries() self.assertEqual(len(batteries), 2) self.assertEqual(batteries[0].part_number, "XXX-G") self.assertEqual(batteries[0].serial_number, "TGXXX") self.assertEqual(batteries[0].energy_remaining, 7378) self.assertEqual(batteries[0].capacity, 14031) self.assertEqual(batteries[0].energy_charged, 5525740) self.assertEqual(batteries[0].energy_discharged, 4659550) self.assertEqual(batteries[0].wobble_detected, False) self.assertEqual(batteries[0].p_out, -1830) self.assertEqual(batteries[0].i_out, 39) self.assertEqual(batteries[0].f_out, 50.067) self.assertEqual(batteries[0].q_out, 30) self.assertEqual(batteries[0].v_out, 226.60000000000002) self.assertEqual(batteries[0].grid_state, GridState.COMPLIANT) self.aresponses.assert_plan_strictly_followed() async def test_islanding_mode_offgrid(self): self.add_response( "v2/islanding/mode", method="POST", body=ISLANDING_MODE_OFFGRID_RESPONSE ) mode = await self.powerwall.set_island_mode(IslandMode.OFFGRID) self.assertEqual(mode, IslandMode.OFFGRID) self.aresponses.assert_plan_strictly_followed() async def test_islanding_mode_ongrid(self): self.add_response( "v2/islanding/mode", method="POST", body=ISLANDING_MODE_ONGRID_RESPONSE ) mode = await self.powerwall.set_island_mode(IslandMode.ONGRID) self.assertEqual(mode, IslandMode.ONGRID) self.aresponses.assert_plan_strictly_followed() def test_helpers(self): resp = {"a": 1} with self.assertRaises(MissingAttributeError): assert_attribute(resp, "test") with self.assertRaises(MissingAttributeError): assert_attribute(resp, "test", "test") self.assertEqual(convert_to_kw(2500, -1), 2.5) async def test_close(self): api_session = None async with Powerwall(ENDPOINT) as powerwall: api_session = powerwall._api._http_session self.assertFalse(api_session.closed) self.assertTrue(api_session.closed) async with aiohttp.ClientSession() as session: api_session = session async with Powerwall(ENDPOINT, http_session=session) as powerwall: self.assertFalse(api_session.closed) self.assertFalse(api_session.closed) self.assertTrue(api_session.closed) powerwall = Powerwall(ENDPOINT) api_session = powerwall._api._http_session self.assertFalse(api_session.closed) await powerwall.close() self.assertTrue(api_session.closed) async with aiohttp.ClientSession() as session: api_session = session powerwall = Powerwall(ENDPOINT, http_session=session) self.assertFalse(api_session.closed) await powerwall.close() self.assertFalse(api_session.closed) self.assertTrue(api_session.closed) jrester-tesla_powerwall-a76f10b/tox.ini000066400000000000000000000004551455126130200203200ustar00rootroot00000000000000[tox] envlist = testenv [testenv] deps = aresponses commands = python -m unittest discover {posargs:tests/unit} [testenv:unit] commands = python -m unittest discover tests/unit [testenv:integration] passenv = POWERWALL_IP,POWERWALL_PASSWORD commands = python -m unittest discover tests/integration