├── tests ├── __init__.py ├── unit │ ├── fixtures │ │ ├── islanding_mode_ongrid.json │ │ ├── islanding_mode_offgrid.json │ │ ├── grid_status.json │ │ ├── sitemaster.json │ │ ├── operation.json │ │ ├── status.json │ │ ├── site_info.json │ │ ├── meter_solar.json │ │ ├── meter_site.json │ │ ├── meters_aggregates.json │ │ ├── system_status.json │ │ └── powerwalls.json │ ├── __init__.py │ ├── test_api.py │ └── test_powerwall.py └── integration │ ├── __init__.py │ └── test_powerwall.py ├── .envrc ├── tesla_powerwall ├── py.typed ├── helpers.py ├── __init__.py ├── error.py ├── const.py ├── powerwall.py ├── api.py └── responses.py ├── tox.ini ├── .pre-commit-config.yaml ├── .github └── workflows │ ├── release.yml │ └── python-publish.yml ├── LICENSE ├── pyproject.toml ├── examples └── example.py ├── .gitignore ├── CHANGELOG └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | layout python 2 | -------------------------------------------------------------------------------- /tesla_powerwall/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/fixtures/islanding_mode_ongrid.json: -------------------------------------------------------------------------------- 1 | { 2 | "island_mode": "backup" 3 | } 4 | -------------------------------------------------------------------------------- /tests/unit/fixtures/islanding_mode_offgrid.json: -------------------------------------------------------------------------------- 1 | { 2 | "island_mode": "intentional_reconnect_failsafe" 3 | } 4 | -------------------------------------------------------------------------------- /tests/unit/fixtures/grid_status.json: -------------------------------------------------------------------------------- 1 | { 2 | "grid_services_active": false, 3 | "grid_status": "SystemGridConnected" 4 | } 5 | -------------------------------------------------------------------------------- /tests/unit/fixtures/sitemaster.json: -------------------------------------------------------------------------------- 1 | { 2 | "connected_to_tesla": true, 3 | "power_supply_mode": false, 4 | "running": true, 5 | "status": "StatusUp" 6 | } 7 | -------------------------------------------------------------------------------- /tests/unit/fixtures/operation.json: -------------------------------------------------------------------------------- 1 | { 2 | "backup_reserve_percent": 5.000019999999999, 3 | "freq_shift_load_shed_delta_f": 0, 4 | "freq_shift_load_shed_soe": 0, 5 | "real_mode": "self_consumption" 6 | } 7 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | ENV_POWERWALL_IP = "POWERWALL_IP" 4 | ENV_POWERWALL_PASSWORD = "POWERWALL_PASSWORD" 5 | 6 | POWERWALL_IP = os.environ[ENV_POWERWALL_IP] 7 | POWERWALL_PASSWORD = os.environ[ENV_POWERWALL_PASSWORD] 8 | -------------------------------------------------------------------------------- /tests/unit/fixtures/status.json: -------------------------------------------------------------------------------- 1 | { 2 | "commission_count": 0, 3 | "device_type": "hec", 4 | "git_hash": "d0e69bde519634961cca04a616d2d4dae80b9f61", 5 | "is_new": false, 6 | "start_time": "2020-10-28 20:14:11 +0800", 7 | "sync_type": "v1", 8 | "up_time_seconds": "17h11m31.214751424s", 9 | "version": "1.50.1 c58c2df3" 10 | } 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = testenv 3 | 4 | [testenv] 5 | deps = aresponses 6 | commands = python -m unittest discover {posargs:tests/unit} 7 | 8 | [testenv:unit] 9 | commands = python -m unittest discover tests/unit 10 | 11 | [testenv:integration] 12 | passenv = POWERWALL_IP,POWERWALL_PASSWORD 13 | commands = python -m unittest discover tests/integration 14 | 15 | [testenv:example] 16 | passenv = POWERWALL_IP,POWERWALL_PASSWORD 17 | commands = python examples/example.py 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v3.4.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: pretty-format-json 8 | args: [--autofix] 9 | - repo: https://github.com/astral-sh/ruff-pre-commit 10 | # Ruff version. 11 | rev: v0.8.6 12 | hooks: 13 | - id: ruff 14 | args: [ --fix ] 15 | - id: ruff-format 16 | - repo: https://github.com/pre-commit/mirrors-mypy 17 | rev: v1.8.0 18 | hooks: 19 | - id: mypy 20 | additional_dependencies: ["types-requests"] 21 | -------------------------------------------------------------------------------- /tesla_powerwall/helpers.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from .error import MissingAttributeError 4 | 5 | 6 | def convert_to_kw(value: float, precision: int = 1) -> float: 7 | """Converts watt to kilowatt and rounds to precision""" 8 | # Don't round if precision is -1 9 | if precision == -1: 10 | return value / 1000 11 | else: 12 | return round(value / 1000, precision) 13 | 14 | 15 | def assert_attribute(response: dict, attr: str, url: Union[str, None] = None): 16 | value = response.get(attr) 17 | if value is None: 18 | raise MissingAttributeError(response, attr, url) 19 | else: 20 | return value 21 | -------------------------------------------------------------------------------- /tests/unit/fixtures/site_info.json: -------------------------------------------------------------------------------- 1 | { 2 | "grid_code": { 3 | "country": "Germany", 4 | "distributor": "*", 5 | "grid_code": "test_grid_code", 6 | "grid_freq_setting": 50, 7 | "grid_phase_setting": "Single", 8 | "grid_voltage_setting": 230, 9 | "region": "test_grid_code_region", 10 | "retailer": "*", 11 | "state": "*", 12 | "utility": "*" 13 | }, 14 | "max_site_meter_power_kW": 1000000000, 15 | "max_system_energy_kWh": 0, 16 | "max_system_power_kW": 0, 17 | "min_site_meter_power_kW": -1000000000, 18 | "nominal_system_energy_kWh": 27, 19 | "nominal_system_power_kW": 10, 20 | "site_name": "test", 21 | "timezone": "Europe/Berlin" 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # Sequence of patterns matched against refs/tags 4 | tags: 5 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 6 | 7 | name: Create Release 8 | 9 | jobs: 10 | build: 11 | name: Create Release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v2 16 | - name: Create Release 17 | id: create_release 18 | uses: actions/create-release@v1 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token 21 | with: 22 | tag_name: ${{ github.ref }} 23 | release_name: ${{ github.ref }} 24 | draft: false 25 | prerelease: false 26 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | - published 8 | - released 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.9' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /tesla_powerwall/__init__.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: F401 2 | 3 | from .api import API 4 | from .const import ( 5 | SUPPORTED_OPERATION_MODES, 6 | DeviceType, 7 | GridState, 8 | GridStatus, 9 | IslandMode, 10 | LineStatus, 11 | MeterType, 12 | OperationMode, 13 | Roles, 14 | SyncType, 15 | User, 16 | ) 17 | from .error import ( 18 | AccessDeniedError, 19 | ApiError, 20 | MeterNotAvailableError, 21 | MissingAttributeError, 22 | PowerwallError, 23 | PowerwallUnreachableError, 24 | ) 25 | from .helpers import assert_attribute, convert_to_kw 26 | from .powerwall import Powerwall 27 | from .responses import ( 28 | BatteryResponse, 29 | LoginResponse, 30 | MeterDetailsReadings, 31 | MeterDetailsResponse, 32 | MeterResponse, 33 | MetersAggregatesResponse, 34 | PowerwallStatusResponse, 35 | SiteInfoResponse, 36 | SiteMasterResponse, 37 | SolarResponse, 38 | ) 39 | 40 | VERSION = "0.5.2" 41 | 42 | __all__ = list(filter(lambda n: not n.startswith("_"), globals().keys())) 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jrester 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | ENDPOINT_SCHEME = "https://" 5 | ENDPOINT_HOST = "1.1.1.1" 6 | ENDPOINT_PATH = "/api/" 7 | ENDPOINT = f"{ENDPOINT_SCHEME}{ENDPOINT_HOST}{ENDPOINT_PATH}" 8 | 9 | FIXTURE_BASE_PATH = Path("tests/unit/fixtures") 10 | 11 | 12 | def load_fixture(name: str): 13 | path = FIXTURE_BASE_PATH / name 14 | with open(path) as f: 15 | return json.load(f) 16 | 17 | 18 | GRID_STATUS_RESPONSE = load_fixture("grid_status.json") 19 | ISLANDING_MODE_OFFGRID_RESPONSE = load_fixture("islanding_mode_offgrid.json") 20 | ISLANDING_MODE_ONGRID_RESPONSE = load_fixture("islanding_mode_ongrid.json") 21 | METER_SITE_RESPONSE = load_fixture("meter_site.json") 22 | METER_SOLAR_RESPONSE = load_fixture("meter_solar.json") 23 | METERS_AGGREGATES_RESPONSE = load_fixture("meters_aggregates.json") 24 | OPERATION_RESPONSE = load_fixture("operation.json") 25 | POWERWALLS_RESPONSE = load_fixture("powerwalls.json") 26 | SITE_INFO_RESPONSE = load_fixture("site_info.json") 27 | SITEMASTER_RESPONSE = load_fixture("sitemaster.json") 28 | STATUS_RESPONSE = load_fixture("status.json") 29 | SYSTEM_STATUS_RESPONSE = load_fixture("system_status.json") 30 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "tesla_powerwall" 7 | version = "0.5.2" 8 | description = "A simple API for accessing the Tesla Powerwall over your local network" 9 | readme = "README.md" 10 | license = { file = "LICENSE"} 11 | classifiers = [ 12 | "License :: OSI Approved :: MIT License", 13 | "Programming Language :: Python :: 3", 14 | ] 15 | keywords = ["api", "tesla", "powerwall", "tesla_powerwall"] 16 | dependencies = [ 17 | "aiohttp>=3.7.4", 18 | "urllib3>=1.26.18", 19 | "orjson>=3.9.0", 20 | "yarl>=1.15.0" 21 | ] 22 | 23 | [project.urls] 24 | Homepage = "https://github.com/jrester/tesla_powerwall" 25 | 26 | [project.optional-dependencies] 27 | test = [ 28 | "tox", 29 | "pre-commit", 30 | ] 31 | 32 | [tool.ruff.lint] 33 | select = ["E4", "E7", "E9", "I"] 34 | 35 | [tool.ruff.lint.per-file-ignores] 36 | "__init__.py" = ["E402", "F401"] 37 | 38 | [tool.coverage.run] 39 | source = ["tesla_powerwall"] 40 | 41 | [tool.isort] 42 | multi_line_output = 3 43 | include_trailing_comma = true 44 | force_grid_wrap = 0 45 | use_parentheses = true 46 | ensure_newline_before_comments = true 47 | -------------------------------------------------------------------------------- /tests/unit/fixtures/meter_solar.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Cached_readings": { 4 | "energy_exported": 30088.217501777115, 5 | "energy_exported_a": 30104.78111111111, 6 | "energy_imported": 18346702.535001777, 7 | "energy_imported_a": 18336727.701388888, 8 | "frequency": 49.95000076293945, 9 | "i_a_current": 0, 10 | "i_b_current": 0, 11 | "i_c_current": 0, 12 | "instant_apparent_power": 2869.7488146485034, 13 | "instant_average_current": 0, 14 | "instant_average_voltage": 250.72000122070312, 15 | "instant_power": 2867.159912109375, 16 | "instant_reactive_power": -121.87000274658203, 17 | "instant_total_current": 0, 18 | "last_communication_time": "2023-04-08T13:01:26.68807076+01:00", 19 | "last_phase_energy_communication_time": "2023-04-08T08:02:36.955494546+01:00", 20 | "last_phase_power_communication_time": "2023-04-08T13:01:26.68807076+01:00", 21 | "last_phase_voltage_communication_time": "2023-04-08T13:01:26.68811376+01:00", 22 | "reactive_power_a": -121.87000274658203, 23 | "real_power_a": 2867.159912109375, 24 | "serial_number": "OBB1234567890", 25 | "timeout": 1500000000, 26 | "v_l1n": 250.72000122070312, 27 | "version": "1.7.1-Tesla" 28 | }, 29 | "connection": { 30 | "device_serial": "OBB1234567890", 31 | "https_conf": {}, 32 | "ip_address": "PWRview-12345", 33 | "neurio_connected": true, 34 | "port": 443, 35 | "short_id": "12345" 36 | }, 37 | "cts": [ 38 | false, 39 | false, 40 | false, 41 | true 42 | ], 43 | "id": 0, 44 | "inverted": [ 45 | false, 46 | false, 47 | false, 48 | false 49 | ], 50 | "location": "solar", 51 | "type": "neurio_tcp" 52 | } 53 | ] 54 | -------------------------------------------------------------------------------- /tests/unit/fixtures/meter_site.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Cached_readings": { 4 | "energy_exported": 1152130.009160081, 5 | "energy_exported_a": 1192873.1919444446, 6 | "energy_exported_b": 1713.8994444444445, 7 | "energy_imported": 15614764.694437858, 8 | "energy_imported_a": 15654958.373333333, 9 | "energy_imported_b": 6.539722222222222, 10 | "frequency": 50, 11 | "i_a_current": 0, 12 | "i_b_current": 0, 13 | "i_c_current": 0, 14 | "instant_apparent_power": 431.96520416598025, 15 | "instant_average_current": 0, 16 | "instant_average_voltage": 123.87999878078699, 17 | "instant_power": -18.00000076368451, 18 | "instant_reactive_power": -431.5900109857321, 19 | "instant_total_current": 0, 20 | "last_communication_time": "2023-04-08T12:55:00.180984196+01:00", 21 | "last_phase_energy_communication_time": "2023-04-07T14:44:17.625943287+01:00", 22 | "last_phase_power_communication_time": "2023-04-08T12:55:00.180984196+01:00", 23 | "last_phase_voltage_communication_time": "2023-04-08T12:55:00.181027196+01:00", 24 | "reactive_power_a": -431.4800109863281, 25 | "reactive_power_b": -0.10999999940395355, 26 | "real_power_a": -17.950000762939453, 27 | "real_power_b": -0.05000000074505806, 28 | "serial_number": "OBB1234567890", 29 | "timeout": 1500000000, 30 | "v_l1n": 247.55999755859375, 31 | "v_l2n": 0.20000000298023224, 32 | "version": "1.7.1-Tesla" 33 | }, 34 | "connection": { 35 | "device_serial": "OBB1234567890", 36 | "https_conf": {}, 37 | "ip_address": "PWRview-12345", 38 | "neurio_connected": true, 39 | "port": 443, 40 | "short_id": "12345" 41 | }, 42 | "cts": [ 43 | true, 44 | true, 45 | false, 46 | false 47 | ], 48 | "id": 0, 49 | "inverted": [ 50 | false, 51 | false, 52 | false, 53 | false 54 | ], 55 | "location": "site", 56 | "type": "neurio_tcp" 57 | } 58 | ] 59 | -------------------------------------------------------------------------------- /examples/example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | 4 | from tesla_powerwall import MeterResponse, Powerwall 5 | 6 | 7 | def getenv(var): 8 | val = os.getenv(var) 9 | if val is None: 10 | raise ValueError(f"{var} must be set") 11 | return val 12 | 13 | 14 | def print_meter_row(meter_data: MeterResponse): 15 | print( 16 | "{:>8} {:>8} {:>17} {:>17} {!r:>8} {!r:>17} {!r:>17}".format( 17 | meter_data.meter.value, 18 | meter_data.get_power(), 19 | meter_data.get_energy_exported(), 20 | meter_data.get_energy_imported(), 21 | meter_data.is_active(), 22 | meter_data.is_drawing_from(), 23 | meter_data.is_sending_to(), 24 | ) 25 | ) 26 | 27 | 28 | async def main(): 29 | ip = getenv("POWERWALL_IP") 30 | password = getenv("POWERWALL_PASSWORD") 31 | 32 | power_wall = Powerwall(ip) 33 | await power_wall.login(password) 34 | site_name = (await power_wall.get_site_info()).site_name 35 | meters_agg = await power_wall.get_meters() 36 | 37 | print(f"{site_name}:\n") 38 | 39 | row_format = "{:>18}: {}" 40 | 41 | values = [ 42 | ("Charge (%)", round(await power_wall.get_charge())), 43 | ("Capacity", await power_wall.get_capacity()), 44 | ("Nominal Energy", await power_wall.get_energy()), 45 | ("Grid Status", (await power_wall.get_grid_status()).value), 46 | ("Backup Reserve (%)", round(await power_wall.get_backup_reserve_percentage())), 47 | ("Device Type", (await power_wall.get_device_type()).value), 48 | ("Software Version", await power_wall.get_version()), 49 | ] 50 | 51 | for val in values: 52 | print(row_format.format(*val)) 53 | 54 | print("\n") 55 | 56 | print( 57 | "{:>8} {:>8} {:>17} {:>17} {:>8} {:>17} {:>17}".format( 58 | "Meter", 59 | "Power", 60 | "Energy exported", 61 | "Energy imported", 62 | "Active", 63 | "Drawing from", 64 | "Sending to", 65 | ) 66 | ) 67 | 68 | for meter in meters_agg.meters.values(): 69 | print_meter_row(meter) 70 | 71 | await power_wall.close() 72 | 73 | 74 | asyncio.run(main()) 75 | -------------------------------------------------------------------------------- /tests/unit/fixtures/meters_aggregates.json: -------------------------------------------------------------------------------- 1 | { 2 | "battery": { 3 | "energy_exported": 4379890, 4 | "energy_imported": 5265110, 5 | "frequency": 49.995000000000005, 6 | "i_a_current": 0, 7 | "i_b_current": 0, 8 | "i_c_current": 0, 9 | "instant_apparent_power": 600.0833275470999, 10 | "instant_average_voltage": 230.8, 11 | "instant_power": -10, 12 | "instant_reactive_power": 600, 13 | "instant_total_current": -0.4, 14 | "last_communication_time": "2020-04-09T05:50:38.990165237-07:00", 15 | "timeout": 1500000000 16 | }, 17 | "load": { 18 | "energy_exported": 0, 19 | "energy_imported": 24751111.13611111, 20 | "frequency": 49.99971389770508, 21 | "i_a_current": 0, 22 | "i_b_current": 0, 23 | "i_c_current": 0, 24 | "instant_apparent_power": 871.7066645380579, 25 | "instant_average_voltage": 232.0439249674479, 26 | "instant_power": 734.1549565813606, 27 | "instant_reactive_power": -469.988307011022, 28 | "instant_total_current": 3.1638620001982423, 29 | "last_communication_time": "2020-04-09T05:50:38.974944676-07:00", 30 | "timeout": 1500000000 31 | }, 32 | "site": { 33 | "energy_exported": 5512641.122754764, 34 | "energy_imported": 9852397.795532543, 35 | "frequency": 49.99971389770508, 36 | "i_a_current": 0, 37 | "i_b_current": 0, 38 | "i_c_current": 0, 39 | "instant_apparent_power": 5388.546173843879, 40 | "instant_average_voltage": 232.0439249674479, 41 | "instant_power": -5347.455078125, 42 | "instant_reactive_power": -664.1942901611328, 43 | "instant_total_current": 0, 44 | "last_communication_time": "2020-04-09T05:50:38.989687241-07:00", 45 | "timeout": 1500000000 46 | }, 47 | "solar": { 48 | "energy_exported": 21296639.987777833, 49 | "energy_imported": 65.52444450131091, 50 | "frequency": 49.95012283325195, 51 | "i_a_current": 0, 52 | "i_b_current": 0, 53 | "i_c_current": 0, 54 | "instant_apparent_power": 6113.633873631454, 55 | "instant_average_voltage": 232.1537322998047, 56 | "instant_power": 6099.032958984375, 57 | "instant_reactive_power": -422.27491760253906, 58 | "instant_total_current": 0, 59 | "last_communication_time": "2020-04-09T05:50:38.974944676-07:00", 60 | "timeout": 1500000000 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # pipenv 86 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 87 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 88 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 89 | # install all needed dependencies. 90 | #Pipfile.lock 91 | 92 | # celery beat schedule file 93 | celerybeat-schedule 94 | 95 | # SageMath parsed files 96 | *.sage.py 97 | 98 | # Environments 99 | .env 100 | .venv 101 | env/ 102 | venv/ 103 | ENV/ 104 | env.bak/ 105 | venv.bak/ 106 | 107 | # Spyder project settings 108 | .spyderproject 109 | .spyproject 110 | 111 | # Rope project settings 112 | .ropeproject 113 | 114 | # mkdocs documentation 115 | /site 116 | 117 | # mypy 118 | .mypy_cache/ 119 | .dmypy.json 120 | dmypy.json 121 | 122 | # Pyre type checker 123 | .pyre/ 124 | 125 | .js 126 | .vscode 127 | .direnv 128 | 129 | # MacOS finder stuff 130 | .DS_Store 131 | -------------------------------------------------------------------------------- /tesla_powerwall/error.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | from .const import MeterType 4 | 5 | 6 | class PowerwallError(Exception): 7 | def __init__(self, msg: str): 8 | super().__init__(msg) 9 | 10 | 11 | class ApiError(PowerwallError): 12 | def __init__(self, error: str): 13 | super().__init__("Powerwall api error: {}".format(error)) 14 | 15 | 16 | class MissingAttributeError(ApiError): 17 | def __init__(self, response: dict, attribute: str, url: Union[str, None] = None): 18 | self.response: dict = response 19 | self.attribute: str = attribute 20 | self.url: Union[str, None] = url 21 | 22 | if url is None: 23 | super().__init__( 24 | "The attribute '{}' is expected in the response but is missing.".format( 25 | attribute 26 | ) 27 | ) 28 | else: 29 | super().__init__( 30 | "The attribute '{}' is expected in the response for \ 31 | '{}' but is missing.".format(attribute, url) 32 | ) 33 | 34 | 35 | class PowerwallUnreachableError(PowerwallError): 36 | def __init__(self, reason: Union[str, None] = None): 37 | msg = "Powerwall is unreachable" 38 | self.reason: Union[str, None] = reason 39 | if reason is not None: 40 | msg = "{}: {}".format(msg, reason) 41 | super().__init__(msg) 42 | 43 | 44 | class AccessDeniedError(PowerwallError): 45 | def __init__( 46 | self, 47 | resource: str, 48 | error: Union[str, None] = None, 49 | message: Union[str, None] = None, 50 | ): 51 | self.resource: str = resource 52 | self.error: Union[str, None] = error 53 | self.message: Union[str, None] = message 54 | msg = "Access denied for resource {}".format(resource) 55 | if error is not None: 56 | if message is not None: 57 | msg = "{}: {}: {}".format(msg, error, message) 58 | else: 59 | msg = "{}: {}".format(msg, error) 60 | super().__init__(msg) 61 | 62 | 63 | class MeterNotAvailableError(PowerwallError): 64 | def __init__(self, meter: MeterType, available_meters: List[MeterType]): 65 | self.meter: MeterType = meter 66 | self.available_meters: List[MeterType] = available_meters 67 | super().__init__( 68 | "Meter {} is not available at your powerwall. \ 69 | Following meters are available: {} ".format(meter.value, available_meters) 70 | ) 71 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.5.2] 4 | 5 | - Use yarl for URL parsing by @bdraco (https://github.com/jrester/tesla_powerwall/pull/62) 6 | - Correctly handle disabled battery packs (https://github.com/jrester/tesla_powerwall/pull/66/) 7 | 8 | ## [0.5.1] 9 | 10 | - Use orjson for parsing json (https://github.com/jrester/tesla_powerwall/pull/59) 11 | - Expose low level information for each battery pack (https://github.com/jrester/tesla_powerwall/pull/60) 12 | 13 | ## [0.5.0] 14 | 15 | - BREAKING: The API is now async by default (by @bubonicbob) 16 | 17 | ## [0.4.0] 18 | 19 | - fix logout (https://github.com/jrester/tesla_powerwall/issues/50) 20 | - add meter details for site and solar (https://github.com/jrester/tesla_powerwall/issues/48) 21 | - rework response handling to now parse the responses directly instead of relying on lazy evaluation 22 | - extend pre-commit hooks 23 | - move to pyproject.toml and remove old setup.py 24 | 25 | 26 | ## [0.3.19] 27 | 28 | - add ability to take powerwall on/off grid. Thanks to @daniel-simpson (https://github.com/jrester/tesla_powerwall/pull/42) 29 | 30 | ## [0.3.18] 31 | 32 | - updated examples 33 | - add Metertype `busway` thanks to @maikukun (https://github.com/jrester/tesla_powerwall/pull/40) 34 | 35 | 36 | ## [0.3.17] 37 | 38 | - move `py.typed` to correct location (https://github.com/jrester/tesla_powerwall/pull/35) 39 | 40 | ## [0.3.16] 41 | 42 | - add `py.typed` file 43 | - remove all the version pinning and drop support for powerwall version < 0.47.0 44 | - add more type hints 45 | - fix 'login_time' attribute in `LoginResponse` 46 | 47 | ## [0.3.15] 48 | 49 | - fix version pin when there is a sha trailer (https://github.com/jrester/tesla_powerwall/pull/34) 50 | - Add support for fetching the gateway_din (https://github.com/jrester/tesla_powerwall/pull/33) 51 | 52 | ## [0.3.14] 53 | 54 | - revert changes from 0.3.11: 55 | - meters can now be accessed using the old, direct method (e.g. `meters.solar.instant_power`) 56 | - if a meter is not available a `MeterNotAvailableError` will be thrown 57 | - move from `distutils.version` to `packaging.version` 58 | 59 | ## [0.3.13] 60 | 61 | Implement `system_status` endpoint (https://github.com/jrester/tesla_powerwall/issues/31): 62 | - add `Battery` response type, which is returned by `get_batteries` 63 | - add `get_energy`, `get_capacity`, `get_batteries` 64 | 65 | ## [0.3.12] 66 | 67 | - add MeterType `generator` (https://github.com/jrester/tesla_powerwall/issues/30) 68 | 69 | ## [0.3.11] 70 | 71 | - meters of `MetersAggregates` can now only be accessed via `get_meter` (https://github.com/home-assistant/core/issues/56660) 72 | -------------------------------------------------------------------------------- /tesla_powerwall/const.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | DEFAULT_KW_ROUND_PERSICION = 1 4 | 5 | 6 | class User(Enum): 7 | INSTALLER = "installer" 8 | CUSTOMER = "customer" 9 | ENGINEER = "engineer" 10 | KIOSK = "kiosk" 11 | ADMIN = "admin" 12 | 13 | 14 | class Roles(Enum): 15 | HOME_OWNER = "Home_Owner" 16 | KIOSK_VIEWER = "Kiosk_Viewer" 17 | PROVIDER_ENGINEER = "Provider_Engineer" 18 | TESLA_ENGINEER = "Tesla_Engineer" 19 | 20 | 21 | class GridStatus(Enum): 22 | CONNECTED = "SystemGridConnected" 23 | ISLANDED_READY = "SystemIslandedReady" 24 | ISLANDED = "SystemIslandedActive" 25 | TRANSITION_TO_GRID = "SystemTransitionToGrid" # Used in version 1.46.0 26 | TRANSITION_TO_ISLAND = "SystemTransitionToIsland" 27 | WAIT_FOR_USER = "SystemWaitForUser" 28 | 29 | 30 | class IslandMode(Enum): 31 | OFFGRID = "intentional_reconnect_failsafe" 32 | ONGRID = "backup" 33 | 34 | 35 | class GridState(Enum): 36 | DISABLED = "Disabled" 37 | COMPLIANT = "Grid_Compliant" 38 | QUALIFYING = "Grid_Qualifying" 39 | UNCOMPLIANT = "Grid_Uncompliant" 40 | 41 | 42 | class LineStatus(Enum): 43 | NON_BACKUP = "NonBackup" 44 | BACKUP = "Backup" 45 | NOT_CONFIGURED = "NotConfigured" 46 | 47 | 48 | class OperationMode(Enum): 49 | BACKUP = "backup" 50 | SELF_CONSUMPTION = "self_consumption" 51 | AUTONOMOUS = "autonomous" 52 | SCHEDULER = "scheduler" 53 | SITE_CONTROL = "site_control" 54 | 55 | 56 | SUPPORTED_OPERATION_MODES = [ 57 | OperationMode.BACKUP, 58 | OperationMode.SELF_CONSUMPTION, 59 | OperationMode.AUTONOMOUS, 60 | ] 61 | 62 | 63 | class InterfaceType(Enum): 64 | ETH = "EthType" 65 | GSM = "GsmType" 66 | WIFI = "WifiType" 67 | 68 | 69 | class MeterType(Enum): 70 | SOLAR = "solar" 71 | SITE = "site" 72 | BATTERY = "battery" 73 | LOAD = "load" 74 | GENERATOR = "generator" 75 | BUSWAY = "busway" 76 | 77 | 78 | class DeviceType(Enum): 79 | """ 80 | Devicetype as returned by "device_type" 81 | GW1: Gateway 1 82 | GW2: Gateway 2 83 | SMC: ? 84 | """ 85 | 86 | GW1 = "hec" 87 | GW2 = "teg" 88 | SMC = "smc" 89 | 90 | 91 | class SyncType(Enum): 92 | V1 = "v1" 93 | V2 = "v2" 94 | V2_1 = "v2.1" 95 | 96 | 97 | class UpdateState(Enum): 98 | CHECKING = "/clear_update_status" 99 | SUCCEEDED = "/update_succeeded" 100 | FAILED = "/update_failed" 101 | STAGED = "/update_staged" 102 | DOWNLOAD = "/download" 103 | DOWNLOADED = "/update_downloaded" 104 | UNKNOWN = "/update_unknown" 105 | 106 | 107 | class UpdateStatus(Enum): 108 | IGNORING = "ignoring" 109 | ERROR = "error" 110 | NONACTIONABLE = "nonactionable" 111 | -------------------------------------------------------------------------------- /tests/unit/fixtures/system_status.json: -------------------------------------------------------------------------------- 1 | { 2 | "all_enable_lines_high": true, 3 | "auxiliary_load": 0, 4 | "available_blocks": 2, 5 | "battery_blocks": [ 6 | { 7 | "OpSeqState": "Active", 8 | "PackagePartNumber": "XXX-G", 9 | "PackageSerialNumber": "TGXXX", 10 | "Type": "", 11 | "backup_ready": true, 12 | "charge_power_clamped": false, 13 | "disabled_reasons": [], 14 | "energy_charged": 5525740, 15 | "energy_discharged": 4659550, 16 | "f_out": 50.067, 17 | "i_out": 39, 18 | "nominal_energy_remaining": 7378, 19 | "nominal_full_pack_energy": 14031, 20 | "off_grid": false, 21 | "p_out": -1830, 22 | "pinv_grid_state": "Grid_Compliant", 23 | "pinv_state": "PINV_GridFollowing", 24 | "q_out": 30, 25 | "v_out": 226.60000000000002, 26 | "version": "67f943cb05d12d", 27 | "vf_mode": false, 28 | "wobble_detected": false 29 | }, 30 | { 31 | "OpSeqState": "Active", 32 | "PackagePartNumber": "XXX-G", 33 | "PackageSerialNumber": "TGXXX", 34 | "Type": "", 35 | "backup_ready": true, 36 | "charge_power_clamped": false, 37 | "disabled_reasons": [], 38 | "energy_charged": 5547410, 39 | "energy_discharged": 4677070, 40 | "f_out": 50.068, 41 | "i_out": 39.2, 42 | "nominal_energy_remaining": 7429, 43 | "nominal_full_pack_energy": 14047, 44 | "off_grid": false, 45 | "p_out": -1830, 46 | "pinv_grid_state": "Grid_Compliant", 47 | "pinv_state": "PINV_GridFollowing", 48 | "q_out": 30, 49 | "v_out": 230, 50 | "version": "67f943cb05d12d", 51 | "vf_mode": false, 52 | "wobble_detected": false 53 | }, 54 | { 55 | "OpSeqState": "Standby", 56 | "PackagePartNumber": "XXX-E", 57 | "PackageSerialNumber": "XXX", 58 | "Type": "", 59 | "backup_ready": false, 60 | "charge_power_clamped": false, 61 | "disabled_reasons": [ 62 | "DisabledExcessiveVoltageDrop" 63 | ], 64 | "energy_charged": null, 65 | "energy_discharged": null, 66 | "f_out": null, 67 | "i_out": null, 68 | "nominal_energy_remaining": 0, 69 | "nominal_full_pack_energy": 14714, 70 | "off_grid": false, 71 | "p_out": null, 72 | "pinv_grid_state": "", 73 | "pinv_state": "", 74 | "q_out": null, 75 | "v_out": null, 76 | "version": "eb113390162784", 77 | "vf_mode": false, 78 | "wobble_detected": false 79 | } 80 | ], 81 | "battery_target_power": -3646.2544361664613, 82 | "battery_target_reactive_power": 0, 83 | "blocks_controlled": 2, 84 | "can_reboot": "Power flow is too high", 85 | "command_source": "Configuration", 86 | "expected_energy_remaining": 0, 87 | "ffr_power_availability_high": 9200, 88 | "ffr_power_availability_low": 9200, 89 | "grid_faults": [ 90 | { 91 | "alert_is_fault": false, 92 | "alert_name": "PINV_a008_vfCheckRocof", 93 | "alert_raw": 576460752303423488, 94 | "decoded_alert": "[{\"name\":\"PINV_alertID\",\"value\":\"PINV_a008_vfCheckRocof\"},{\"name\":\"PINV_alertType\",\"value\":\"Warning\"}]", 95 | "ecu_package_part_number": "XXX-J", 96 | "ecu_package_serial_number": "TXXX", 97 | "ecu_type": "TEPINV", 98 | "git_hash": "67f943cb05d12d", 99 | "site_uid": "TG-XXX", 100 | "timestamp": 1634015591828 101 | }, 102 | { 103 | "alert_is_fault": false, 104 | "alert_name": "PINV_a007_vfCheckOverFrequency", 105 | "alert_raw": 504575983454519296, 106 | "decoded_alert": "[{\"name\":\"PINV_alertID\",\"value\":\"PINV_a007_vfCheckOverFrequency\"},{\"name\":\"PINV_alertType\",\"value\":\"Warning\"},{\"name\":\"PINV_a007_frequency\",\"value\":52.189,\"units\":\"Hz\"}]", 107 | "ecu_package_part_number": "XXX-J", 108 | "ecu_package_serial_number": "TXXX", 109 | "ecu_type": "TEPINV", 110 | "git_hash": "67f943cb05d12d", 111 | "site_uid": "XXX-uid", 112 | "timestamp": 1634015591733 113 | }, 114 | { 115 | "alert_is_fault": false, 116 | "alert_name": "PINV_a004_vfCheckUnderVoltage", 117 | "alert_raw": 288365616081928192, 118 | "decoded_alert": "[{\"name\":\"PINV_alertID\",\"value\":\"PINV_a004_vfCheckUnderVoltage\"},{\"name\":\"PINV_alertType\",\"value\":\"Warning\"},{\"name\":\"PINV_a004_uv_amplitude\",\"value\":123,\"units\":\"Vrms\"}]", 119 | "ecu_package_part_number": "1081100-79-J", 120 | "ecu_package_serial_number": "TXXX", 121 | "ecu_type": "TEPINV", 122 | "git_hash": "67f943cb05d12d", 123 | "site_uid": "TG-XXX", 124 | "timestamp": 1634015591646 125 | } 126 | ], 127 | "grid_services_power": 0, 128 | "instantaneous_max_charge_power": 0, 129 | "instantaneous_max_discharge_power": 0, 130 | "inverter_nominal_usable_power": 9200, 131 | "last_toggle_timestamp": "2021-09-30T18:11:41.110543639+02:00", 132 | "load_charge_constraint": 0, 133 | "max_apparent_power": 9200.000000000002, 134 | "max_charge_power": 9200, 135 | "max_discharge_power": 9200, 136 | "max_power_energy_remaining": 0, 137 | "max_power_energy_to_be_charged": 0, 138 | "max_sustained_ramp_rate": 2500000, 139 | "nominal_energy_remaining": 14807, 140 | "nominal_full_pack_energy": 28078, 141 | "primary": true, 142 | "score": 10000, 143 | "smart_inv_delta_p": 0, 144 | "smart_inv_delta_q": 0, 145 | "solar_real_power_limit": -1, 146 | "system_island_state": "SystemGridConnected" 147 | } 148 | -------------------------------------------------------------------------------- /tests/integration/test_powerwall.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import unittest 3 | 4 | from tesla_powerwall import GridStatus, IslandMode, MeterType, Powerwall 5 | from tesla_powerwall.responses import ( 6 | MeterResponse, 7 | MetersAggregatesResponse, 8 | PowerwallStatusResponse, 9 | SiteInfoResponse, 10 | SiteMasterResponse, 11 | ) 12 | from tests.integration import POWERWALL_IP, POWERWALL_PASSWORD 13 | 14 | 15 | class TestPowerwall(unittest.IsolatedAsyncioTestCase): 16 | async def asyncSetUp(self): 17 | self.powerwall = Powerwall(POWERWALL_IP) 18 | await self.powerwall.login(POWERWALL_PASSWORD) 19 | assert self.powerwall.is_authenticated() 20 | 21 | async def asyncTearDown(self): 22 | await self.powerwall.close() 23 | await self.http_session.close() 24 | 25 | async def test_get_charge(self) -> None: 26 | charge = await self.powerwall.get_charge() 27 | if charge < 100: 28 | self.assertIsInstance(charge, float) 29 | else: 30 | self.assertEqual(charge, 100) 31 | 32 | async def test_get_meters(self) -> None: 33 | meters = await self.powerwall.get_meters() 34 | self.assertIsInstance(meters, MetersAggregatesResponse) 35 | 36 | self.assertIsInstance(meters.get_meter(MeterType.BATTERY), MeterResponse) 37 | 38 | for meter_type in meters.meters: 39 | meter = meters.get_meter(meter_type) 40 | assert meter is not None 41 | meter.energy_exported 42 | meter.energy_imported 43 | meter.instant_power 44 | meter.last_communication_time 45 | meter.frequency 46 | meter.instant_average_voltage 47 | meter.get_energy_exported() 48 | meter.get_energy_imported() 49 | self.assertIsInstance(meter.get_power(), float) 50 | self.assertIsInstance(meter.is_active(), bool) 51 | self.assertIsInstance(meter.is_drawing_from(), bool) 52 | self.assertIsInstance(meter.is_sending_to(), bool) 53 | 54 | async def test_sitemaster(self) -> None: 55 | sitemaster = await self.powerwall.get_sitemaster() 56 | 57 | self.assertIsInstance(sitemaster, SiteMasterResponse) 58 | 59 | sitemaster.status 60 | sitemaster.is_running 61 | sitemaster.is_connected_to_tesla 62 | sitemaster.is_power_supply_mode 63 | 64 | async def test_site_info(self) -> None: 65 | site_info = await self.powerwall.get_site_info() 66 | 67 | self.assertIsInstance(site_info, SiteInfoResponse) 68 | 69 | site_info.nominal_system_energy 70 | site_info.site_name 71 | site_info.timezone 72 | 73 | async def test_capacity(self) -> None: 74 | self.assertIsInstance(await self.powerwall.get_capacity(), int) 75 | 76 | async def test_energy(self) -> None: 77 | self.assertIsInstance(await self.powerwall.get_energy(), int) 78 | 79 | async def test_batteries(self) -> None: 80 | batteries = await self.powerwall.get_batteries() 81 | self.assertGreater(len(batteries), 0) 82 | for battery in batteries: 83 | battery.wobble_detected 84 | battery.energy_discharged 85 | battery.energy_charged 86 | battery.energy_remaining 87 | battery.capacity 88 | battery.part_number 89 | battery.serial_number 90 | 91 | async def test_grid_status(self) -> None: 92 | grid_status = await self.powerwall.get_grid_status() 93 | self.assertIsInstance(grid_status, GridStatus) 94 | 95 | async def test_status(self) -> None: 96 | status = await self.powerwall.get_status() 97 | self.assertIsInstance(status, PowerwallStatusResponse) 98 | status.up_time_seconds 99 | status.start_time 100 | status.version 101 | 102 | async def test_islanding(self) -> None: 103 | initial_grid_status = await self.powerwall.get_grid_status() 104 | self.assertIsInstance(initial_grid_status, GridStatus) 105 | 106 | if initial_grid_status == GridStatus.CONNECTED: 107 | await self.go_offline() 108 | await self.go_online() 109 | elif initial_grid_status == GridStatus.ISLANDED: 110 | await self.go_offline() 111 | await self.go_online() 112 | 113 | async def go_offline(self) -> None: 114 | observedIslandMode = await self.powerwall.set_island_mode(IslandMode.OFFGRID) 115 | self.assertEqual(observedIslandMode, IslandMode.OFFGRID) 116 | await self.wait_until_grid_status(GridStatus.ISLANDED) 117 | self.assertEqual(await self.powerwall.get_grid_status(), GridStatus.ISLANDED) 118 | 119 | async def go_online(self) -> None: 120 | observedIslandMode = await self.powerwall.set_island_mode(IslandMode.ONGRID) 121 | self.assertEqual(observedIslandMode, IslandMode.ONGRID) 122 | await self.wait_until_grid_status(GridStatus.CONNECTED) 123 | self.assertEqual(await self.powerwall.get_grid_status(), GridStatus.CONNECTED) 124 | 125 | async def wait_until_grid_status( 126 | self, 127 | expectedStatus: GridStatus, 128 | sleepTime: int = 1, 129 | maxCycles: int = 20, 130 | ) -> None: 131 | cycles = 0 132 | observedStatus: GridStatus 133 | 134 | while cycles < maxCycles: 135 | observedStatus = await self.powerwall.get_grid_status() 136 | if observedStatus == expectedStatus: 137 | break 138 | await asyncio.sleep(sleepTime) 139 | cycles = cycles + 1 140 | 141 | self.assertEqual(observedStatus, expectedStatus) 142 | -------------------------------------------------------------------------------- /tesla_powerwall/powerwall.py: -------------------------------------------------------------------------------- 1 | from types import TracebackType 2 | from typing import List, Optional, Type, Union 3 | 4 | import aiohttp 5 | 6 | from .api import API 7 | from .const import DeviceType, GridStatus, IslandMode, OperationMode, User 8 | from .error import ApiError 9 | from .helpers import assert_attribute 10 | from .responses import ( 11 | BatteryResponse, 12 | LoginResponse, 13 | MeterDetailsResponse, 14 | MetersAggregatesResponse, 15 | PowerwallStatusResponse, 16 | SiteInfoResponse, 17 | SiteMasterResponse, 18 | SolarResponse, 19 | ) 20 | 21 | 22 | class Powerwall: 23 | def __init__( 24 | self, 25 | endpoint: str, 26 | timeout: int = 10, 27 | http_session: Union[aiohttp.ClientSession, None] = None, 28 | verify_ssl: bool = False, 29 | ) -> None: 30 | self._api = API( 31 | endpoint=endpoint, 32 | timeout=timeout, 33 | http_session=http_session, 34 | verify_ssl=verify_ssl, 35 | ) 36 | 37 | async def login_as( 38 | self, 39 | user: Union[User, str], 40 | password: str, 41 | email: str, 42 | force_sm_off: bool = False, 43 | ) -> LoginResponse: 44 | if isinstance(user, User): 45 | user = user.value 46 | 47 | response = await self._api.login(user, email, password, force_sm_off) 48 | # The api returns an auth cookie which is automatically set 49 | # so there is no need to further process the response 50 | 51 | return LoginResponse.from_dict(response) 52 | 53 | async def login( 54 | self, password: str, email: str = "", force_sm_off: bool = False 55 | ) -> LoginResponse: 56 | return await self.login_as(User.CUSTOMER, password, email, force_sm_off) 57 | 58 | async def logout(self) -> None: 59 | await self._api.logout() 60 | 61 | def is_authenticated(self) -> bool: 62 | return self._api.is_authenticated() 63 | 64 | async def run(self) -> None: 65 | await self._api.get_sitemaster_run() 66 | 67 | async def stop(self) -> None: 68 | await self._api.get_sitemaster_stop() 69 | 70 | async def get_charge(self) -> Union[float, int]: 71 | return assert_attribute( 72 | await self._api.get_system_status_soe(), "percentage", "soe" 73 | ) 74 | 75 | async def get_energy(self) -> int: 76 | return assert_attribute( 77 | await self._api.get_system_status(), 78 | "nominal_energy_remaining", 79 | "system_status", 80 | ) 81 | 82 | async def get_sitemaster(self) -> SiteMasterResponse: 83 | return SiteMasterResponse.from_dict(await self._api.get_sitemaster()) 84 | 85 | async def get_meters(self) -> MetersAggregatesResponse: 86 | return MetersAggregatesResponse.from_dict( 87 | await self._api.get_meters_aggregates() 88 | ) 89 | 90 | async def get_meter_site(self) -> MeterDetailsResponse: 91 | meter_response = await self._api.get_meters_site() 92 | if meter_response is None or len(meter_response) == 0: 93 | raise ApiError("The powerwall returned no values for the site meter") 94 | 95 | return MeterDetailsResponse.from_dict(meter_response[0]) 96 | 97 | async def get_meter_solar(self) -> MeterDetailsResponse: 98 | meter_response = await self._api.get_meters_solar() 99 | if meter_response is None or len(meter_response) == 0: 100 | raise ApiError("The powerwall returned no values for the solar meter") 101 | 102 | return MeterDetailsResponse.from_dict(meter_response[0]) 103 | 104 | async def get_grid_status(self) -> GridStatus: 105 | """Returns the current grid status.""" 106 | status = assert_attribute( 107 | await self._api.get_system_status_grid_status(), 108 | "grid_status", 109 | "grid_status", 110 | ) 111 | 112 | return GridStatus(status) 113 | 114 | async def get_capacity(self) -> float: 115 | return assert_attribute( 116 | await self._api.get_system_status(), 117 | "nominal_full_pack_energy", 118 | "system_status", 119 | ) 120 | 121 | async def get_batteries(self) -> List[BatteryResponse]: 122 | batteries = assert_attribute( 123 | await self._api.get_system_status(), "battery_blocks", "system_status" 124 | ) 125 | return [BatteryResponse.from_dict(battery) for battery in batteries] 126 | 127 | async def is_grid_services_active(self) -> bool: 128 | return assert_attribute( 129 | await self._api.get_system_status_grid_status(), 130 | "grid_services_active", 131 | "grid_status", 132 | ) 133 | 134 | async def get_site_info(self) -> SiteInfoResponse: 135 | """Returns information about the powerwall site""" 136 | return SiteInfoResponse.from_dict(await self._api.get_site_info()) 137 | 138 | async def set_site_name(self, site_name: str) -> dict: 139 | return await self._api.post_site_info_site_name({"site_name": site_name}) 140 | 141 | async def get_status(self) -> PowerwallStatusResponse: 142 | return PowerwallStatusResponse.from_dict(await self._api.get_status()) 143 | 144 | async def get_device_type(self) -> DeviceType: 145 | """Returns the device type of the powerwall""" 146 | return (await self.get_status()).device_type 147 | 148 | async def get_serial_numbers(self) -> List[str]: 149 | powerwalls = assert_attribute( 150 | await self._api.get_powerwalls(), "powerwalls", "powerwalls" 151 | ) 152 | return [ 153 | assert_attribute(powerwall, "PackageSerialNumber") 154 | for powerwall in powerwalls 155 | ] 156 | 157 | async def get_gateway_din(self) -> str: 158 | """Return the gateway din.""" 159 | return assert_attribute( 160 | await self._api.get_powerwalls(), "gateway_din", "powerwalls" 161 | ) 162 | 163 | async def get_operation_mode(self) -> OperationMode: 164 | operation_mode = assert_attribute( 165 | await self._api.get_operation(), "real_mode", "operation" 166 | ) 167 | return OperationMode(operation_mode) 168 | 169 | async def get_backup_reserve_percentage(self) -> float: 170 | return assert_attribute( 171 | await self._api.get_operation(), "backup_reserve_percent", "operation" 172 | ) 173 | 174 | async def get_solars(self) -> List[SolarResponse]: 175 | return [ 176 | SolarResponse.from_dict(solar) for solar in await self._api.get_solars() 177 | ] 178 | 179 | async def get_vin(self) -> str: 180 | return assert_attribute(await self._api.get_config(), "vin", "config") 181 | 182 | async def set_island_mode(self, mode: IslandMode) -> IslandMode: 183 | return IslandMode( 184 | assert_attribute( 185 | await self._api.post_islanding_mode({"island_mode": mode.value}), 186 | "island_mode", 187 | ) 188 | ) 189 | 190 | async def get_version(self) -> str: 191 | version_str = assert_attribute( 192 | await self._api.get_status(), "version", "status" 193 | ) 194 | return version_str.split(" ")[ 195 | 0 196 | ] # newer versions include a sha trailer '21.44.1 c58c2df3' 197 | 198 | def get_api(self) -> API: 199 | return self._api 200 | 201 | async def close(self) -> None: 202 | await self._api.close() 203 | 204 | async def __aenter__(self) -> "Powerwall": 205 | return self 206 | 207 | async def __aexit__( 208 | self, 209 | exc_type: Optional[Type[BaseException]], 210 | exc_val: Optional[BaseException], 211 | exc_tb: Optional[TracebackType], 212 | ) -> None: 213 | await self.close() 214 | -------------------------------------------------------------------------------- /tests/unit/fixtures/powerwalls.json: -------------------------------------------------------------------------------- 1 | { 2 | "bubble_shedding": false, 3 | "checking_if_offgrid": false, 4 | "enumerating": false, 5 | "gateway_din": "gateway_din", 6 | "grid_code_validating": false, 7 | "grid_qualifying": false, 8 | "has_sync": false, 9 | "on_grid_check_error": "on grid check not run", 10 | "phase_detection_last_error": "phase detection not run", 11 | "phase_detection_not_available": true, 12 | "powerwalls": [ 13 | { 14 | "PackagePartNumber": "PartNumber1", 15 | "PackageSerialNumber": "SerialNumber1", 16 | "Type": "", 17 | "bc_type": null, 18 | "commissioning_diagnostic": { 19 | "category": "InternalComms", 20 | "checks": [ 21 | { 22 | "debug": {}, 23 | "end_time": "2020-10-29T15:02:46.361509132+01:00", 24 | "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", 25 | "name": "CAN connectivity", 26 | "results": {}, 27 | "start_time": "2020-10-29T15:02:46.361506132+01:00", 28 | "status": "fail" 29 | }, 30 | { 31 | "debug": {}, 32 | "end_time": "2020-10-29T15:02:46.361513798+01:00", 33 | "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", 34 | "name": "Enable switch", 35 | "results": {}, 36 | "start_time": "2020-10-29T15:02:46.361511798+01:00", 37 | "status": "fail" 38 | }, 39 | { 40 | "debug": {}, 41 | "end_time": "2020-10-29T15:02:46.361518132+01:00", 42 | "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", 43 | "name": "Internal communications", 44 | "results": {}, 45 | "start_time": "2020-10-29T15:02:46.361516132+01:00", 46 | "status": "fail" 47 | }, 48 | { 49 | "debug": {}, 50 | "end_time": "2020-10-29T15:02:46.361522132+01:00", 51 | "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", 52 | "name": "Firmware up-to-date", 53 | "results": {}, 54 | "start_time": "2020-10-29T15:02:46.361520132+01:00", 55 | "status": "fail" 56 | } 57 | ], 58 | "disruptive": false, 59 | "inputs": null, 60 | "name": "Commissioning" 61 | }, 62 | "grid_reconnection_time_seconds": 0, 63 | "grid_state": "Grid_Uncompliant", 64 | "type": "acpw", 65 | "under_phase_detection": false, 66 | "update_diagnostic": { 67 | "category": "InternalComms", 68 | "checks": [ 69 | { 70 | "debug": null, 71 | "end_time": null, 72 | "name": "Powerwall firmware", 73 | "progress": 0, 74 | "results": null, 75 | "start_time": null, 76 | "status": "not_run" 77 | }, 78 | { 79 | "debug": null, 80 | "end_time": null, 81 | "name": "Battery firmware", 82 | "progress": 0, 83 | "results": null, 84 | "start_time": null, 85 | "status": "not_run" 86 | }, 87 | { 88 | "debug": null, 89 | "end_time": null, 90 | "name": "Inverter firmware", 91 | "progress": 0, 92 | "results": null, 93 | "start_time": null, 94 | "status": "not_run" 95 | }, 96 | { 97 | "debug": null, 98 | "end_time": null, 99 | "name": "Grid code", 100 | "progress": 0, 101 | "results": null, 102 | "start_time": null, 103 | "status": "not_run" 104 | } 105 | ], 106 | "disruptive": true, 107 | "inputs": null, 108 | "name": "Firmware Update" 109 | }, 110 | "updating": false 111 | }, 112 | { 113 | "PackagePartNumber": "PartNumber2", 114 | "PackageSerialNumber": "SerialNumber2", 115 | "Type": "", 116 | "bc_type": null, 117 | "commissioning_diagnostic": { 118 | "category": "InternalComms", 119 | "checks": [ 120 | { 121 | "debug": {}, 122 | "end_time": "2020-10-29T15:02:46.361757797+01:00", 123 | "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", 124 | "name": "CAN connectivity", 125 | "results": {}, 126 | "start_time": "2020-10-29T15:02:46.361754463+01:00", 127 | "status": "fail" 128 | }, 129 | { 130 | "debug": {}, 131 | "end_time": "2020-10-29T15:02:46.36176213+01:00", 132 | "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", 133 | "name": "Enable switch", 134 | "results": {}, 135 | "start_time": "2020-10-29T15:02:46.36176013+01:00", 136 | "status": "fail" 137 | }, 138 | { 139 | "debug": {}, 140 | "end_time": "2020-10-29T15:02:46.36176713+01:00", 141 | "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", 142 | "name": "Internal communications", 143 | "results": {}, 144 | "start_time": "2020-10-29T15:02:46.36176413+01:00", 145 | "status": "fail" 146 | }, 147 | { 148 | "debug": {}, 149 | "end_time": "2020-10-29T15:02:46.361771463+01:00", 150 | "message": "Cannot perform this action with site controller running. From landing page, either \"STOP SYSTEM\" or \"RUN WIZARD\" to proceed.", 151 | "name": "Firmware up-to-date", 152 | "results": {}, 153 | "start_time": "2020-10-29T15:02:46.361769463+01:00", 154 | "status": "fail" 155 | } 156 | ], 157 | "disruptive": false, 158 | "inputs": null, 159 | "name": "Commissioning" 160 | }, 161 | "grid_reconnection_time_seconds": 0, 162 | "grid_state": "Grid_Uncompliant", 163 | "type": "acpw", 164 | "under_phase_detection": false, 165 | "update_diagnostic": { 166 | "category": "InternalComms", 167 | "checks": [ 168 | { 169 | "debug": null, 170 | "end_time": null, 171 | "name": "Powerwall firmware", 172 | "progress": 0, 173 | "results": null, 174 | "start_time": null, 175 | "status": "not_run" 176 | }, 177 | { 178 | "debug": null, 179 | "end_time": null, 180 | "name": "Battery firmware", 181 | "progress": 0, 182 | "results": null, 183 | "start_time": null, 184 | "status": "not_run" 185 | }, 186 | { 187 | "debug": null, 188 | "end_time": null, 189 | "name": "Inverter firmware", 190 | "progress": 0, 191 | "results": null, 192 | "start_time": null, 193 | "status": "not_run" 194 | }, 195 | { 196 | "debug": null, 197 | "end_time": null, 198 | "name": "Grid code", 199 | "progress": 0, 200 | "results": null, 201 | "start_time": null, 202 | "status": "not_run" 203 | } 204 | ], 205 | "disruptive": true, 206 | "inputs": null, 207 | "name": "Firmware Update" 208 | }, 209 | "updating": false 210 | } 211 | ], 212 | "running_phase_detection": false, 213 | "states": [], 214 | "sync": null, 215 | "updating": false 216 | } 217 | -------------------------------------------------------------------------------- /tests/unit/test_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | 4 | import aiohttp 5 | import aresponses 6 | from yarl import URL 7 | 8 | from tesla_powerwall import API, AccessDeniedError, ApiError 9 | from tesla_powerwall.const import User 10 | from tests.unit import ENDPOINT, ENDPOINT_HOST, ENDPOINT_PATH 11 | 12 | 13 | class TestAPI(unittest.IsolatedAsyncioTestCase): 14 | async def asyncSetUp(self): 15 | self.aresponses = aresponses.ResponsesMockServer() 16 | await self.aresponses.__aenter__() 17 | 18 | self.session = aiohttp.ClientSession() 19 | self.api = API(ENDPOINT, http_session=self.session) 20 | 21 | async def asyncTearDown(self): 22 | await self.api.close() 23 | await self.session.close() 24 | await self.aresponses.__aexit__(None, None, None) 25 | 26 | async def test_endpoint_is_pared_correctly(self): 27 | test_endpoints = [ 28 | "1.1.1.1", 29 | "http://1.1.1.1", 30 | "https://1.1.1.1/api/", 31 | "https://1.1.1.1/api", 32 | "https://1.1.1.1/", 33 | ] 34 | 35 | for test_endpoint in test_endpoints: 36 | api = API(test_endpoint) 37 | await api.close() 38 | self.assertEqual(api.url("").scheme, "https") 39 | self.assertTrue(api.url("").path.startswith("/api")) 40 | 41 | async def test_process_response(self): 42 | status = 0 43 | text = None 44 | 45 | def response_handler(request): 46 | return self.aresponses.Response(status=status, text=text) 47 | 48 | self.aresponses.add( 49 | ENDPOINT_HOST, 50 | f"{ENDPOINT_PATH}test", 51 | "GET", 52 | response_handler, 53 | repeat=self.aresponses.INFINITY, 54 | ) 55 | 56 | status = 401 57 | async with self.session.get(f"{ENDPOINT}test") as response: 58 | with self.assertRaises(AccessDeniedError): 59 | await self.api._process_response(response) 60 | 61 | status = 404 62 | async with self.session.get(f"{ENDPOINT}test") as response: 63 | with self.assertRaises(ApiError): 64 | await self.api._process_response(response) 65 | 66 | status = 502 67 | async with self.session.get(f"{ENDPOINT}test") as response: 68 | with self.assertRaises(ApiError): 69 | await self.api._process_response(response) 70 | 71 | status = 200 72 | text = '{"error": "test_error"}' 73 | async with self.session.get(f"{ENDPOINT}test") as response: 74 | with self.assertRaises(ApiError): 75 | await self.api._process_response(response) 76 | 77 | status = 200 78 | text = '{invalid_json"' 79 | async with self.session.get(f"{ENDPOINT}test") as response: 80 | with self.assertRaises(ApiError): 81 | await self.api._process_response(response) 82 | 83 | status = 200 84 | text = "{}" 85 | async with self.session.get(f"{ENDPOINT}test") as response: 86 | self.assertEqual(await self.api._process_response(response), {}) 87 | 88 | status = 200 89 | text = '{"response": "ok"}' 90 | async with self.session.get(f"{ENDPOINT}test") as response: 91 | self.assertEqual( 92 | await self.api._process_response(response), {"response": "ok"} 93 | ) 94 | 95 | async def test_get(self): 96 | self.aresponses.add( 97 | ENDPOINT_HOST, 98 | f"{ENDPOINT_PATH}test_get", 99 | "GET", 100 | self.aresponses.Response(text='{"test_get": true}'), 101 | ) 102 | 103 | self.assertEqual(await self.api.get("test_get"), {"test_get": True}) 104 | 105 | self.aresponses.assert_plan_strictly_followed() 106 | 107 | async def test_post(self): 108 | self.aresponses.add( 109 | ENDPOINT_HOST, 110 | f"{ENDPOINT_PATH}test_post", 111 | "POST", 112 | self.aresponses.Response( 113 | text='{"test_post": true}', headers={"Content-Type": "application/json"} 114 | ), 115 | ) 116 | 117 | resp = await self.api.post("test_post", {"test": True}) 118 | self.assertIsInstance(resp, dict) 119 | self.assertEqual(resp, {"test_post": True}) 120 | 121 | self.aresponses.assert_plan_strictly_followed() 122 | 123 | async def test_is_authenticated(self): 124 | self.assertEqual(self.api.is_authenticated(), False) 125 | 126 | self.session.cookie_jar.update_cookies(cookies={"AuthCookie": "foo"}) 127 | self.assertEqual(self.api.is_authenticated(), True) 128 | 129 | def test_url(self): 130 | self.assertEqual(self.api.url("test"), URL(ENDPOINT + "test")) 131 | 132 | async def test_login(self): 133 | jar = aiohttp.CookieJar(unsafe=True) 134 | async with aiohttp.ClientSession(cookie_jar=jar) as http_session: 135 | async with API(ENDPOINT, http_session=http_session) as api: 136 | username = User.CUSTOMER.value 137 | password = "password" 138 | email = "email@email.com" 139 | 140 | async def response_handler(request) -> aresponses.Response: 141 | request_json = await request.json() 142 | 143 | self.assertEqual(request_json["username"], username) 144 | self.assertEqual(request_json["password"], password) 145 | self.assertEqual(request_json["email"], email) 146 | 147 | login_response = self.aresponses.Response( 148 | text=json.dumps( 149 | { 150 | "email": request_json["email"], 151 | "firstname": "Tesla", 152 | "lastname": "Energy", 153 | "roles": ["Home_Owner"], 154 | "token": "x4jbH...XMP8w==", 155 | "provider": "Basic", 156 | "loginTime": "2023-03-25T13:10:48.9029581+01:00", 157 | } 158 | ), 159 | headers={"Content-Type": "application/json"}, 160 | ) 161 | login_response.set_cookie("AuthCookie", "foo") 162 | return login_response 163 | 164 | self.aresponses.add( 165 | ENDPOINT_HOST, 166 | f"{ENDPOINT_PATH}login/Basic", 167 | "POST", 168 | response_handler, 169 | ) 170 | 171 | await api.login(username=username, email=email, password=password) 172 | 173 | self.aresponses.add( 174 | ENDPOINT_HOST, 175 | f"{ENDPOINT_PATH}logout", 176 | "GET", 177 | self.aresponses.Response( 178 | text="", headers={"Content-Type": "application/json"} 179 | ), 180 | ) 181 | 182 | await api.logout() 183 | 184 | self.aresponses.assert_plan_strictly_followed() 185 | 186 | async def test_close(self): 187 | api_session = None 188 | async with API(ENDPOINT) as api: 189 | api_session = api._http_session 190 | self.assertFalse(api_session.closed) 191 | self.assertTrue(api_session.closed) 192 | 193 | async with aiohttp.ClientSession() as session: 194 | async with API(ENDPOINT, http_session=session) as api: 195 | api_session = api._http_session 196 | self.assertFalse(api_session.closed) 197 | 198 | self.assertFalse(api_session.closed) 199 | self.assertTrue(api_session.closed) 200 | 201 | api = API(ENDPOINT) 202 | api_session = api._http_session 203 | self.assertFalse(api_session.closed) 204 | await api.close() 205 | self.assertTrue(api_session.closed) 206 | 207 | async with aiohttp.ClientSession() as session: 208 | api_session = session 209 | api = API(ENDPOINT, http_session=session) 210 | self.assertFalse(api_session.closed) 211 | await api.close() 212 | self.assertFalse(api_session.closed) 213 | self.assertTrue(api_session.closed) 214 | -------------------------------------------------------------------------------- /tesla_powerwall/api.py: -------------------------------------------------------------------------------- 1 | from http.client import responses 2 | from json.decoder import JSONDecodeError 3 | from types import TracebackType 4 | from typing import Any, List, Optional, Type 5 | 6 | import aiohttp 7 | import orjson 8 | from yarl import URL 9 | 10 | from .error import AccessDeniedError, ApiError, PowerwallUnreachableError 11 | 12 | 13 | class API(object): 14 | def __init__( 15 | self, 16 | endpoint: str, 17 | timeout: int = 10, 18 | http_session: Optional[aiohttp.ClientSession] = None, 19 | verify_ssl: bool = False, 20 | ) -> None: 21 | # Required if endpoint is a single ip address, because yarl does not correctly process them. 22 | if not endpoint.startswith("http"): 23 | endpoint = f"https://{endpoint}" 24 | self._endpoint = URL(endpoint).with_path("api").with_scheme("https") 25 | self._timeout = aiohttp.ClientTimeout(total=timeout) 26 | self._owns_http_session = False if http_session else True 27 | self._ssl = None if verify_ssl else False 28 | 29 | if http_session: 30 | self._owns_http_session = False 31 | self._http_session = http_session 32 | else: 33 | self._owns_http_session = True 34 | 35 | # Allow unsafe cookies so that folks can use IP addresses in their configs 36 | # See: https://docs.aiohttp.org/en/v3.7.3/client_advanced.html#cookie-safety 37 | jar = aiohttp.CookieJar(unsafe=True) 38 | self._http_session = aiohttp.ClientSession(cookie_jar=jar) 39 | 40 | @staticmethod 41 | async def _handle_error(response: aiohttp.ClientResponse) -> None: 42 | if response.status == 404: 43 | raise ApiError( 44 | "The url {} returned error 404".format(str(response.real_url)) 45 | ) 46 | 47 | if response.status == 401 or response.status == 403: 48 | response_json = None 49 | try: 50 | response_json = await response.json(loads=orjson.loads) 51 | except Exception: 52 | raise AccessDeniedError(str(response.real_url)) 53 | else: 54 | raise AccessDeniedError( 55 | str(response.real_url), 56 | response_json.get("error"), 57 | response_json.get("message"), 58 | ) 59 | 60 | response_text = await response.text() 61 | if response_text is not None and len(response_text) > 0: 62 | raise ApiError( 63 | "API returned status code '{}: {}' with body: {}".format( 64 | response.status, 65 | responses.get(response.status), 66 | response_text, 67 | ) 68 | ) 69 | else: 70 | raise ApiError( 71 | "API returned status code '{}: {}' ".format( 72 | response.status, responses.get(response.status) 73 | ) 74 | ) 75 | 76 | async def _process_response(self, response: aiohttp.ClientResponse) -> dict: 77 | if response.status >= 400: 78 | # API returned some sort of error that must be handled 79 | await self._handle_error(response) 80 | 81 | content = await response.read() 82 | if len(content) == 0: 83 | return {} 84 | 85 | try: 86 | response_json = await response.json(content_type=None, loads=orjson.loads) 87 | except JSONDecodeError: 88 | raise ApiError( 89 | "Error while decoding json of response: {}".format(response.text) 90 | ) 91 | 92 | if response_json is None: 93 | return {} 94 | 95 | # Newer versions of the powerwall do not return such values anymore 96 | # Kept for backwards compability or if the API changes again 97 | if "error" in response_json: 98 | raise ApiError(response_json["error"]) 99 | 100 | return response_json 101 | 102 | def url(self, path: str) -> URL: 103 | return self._endpoint.joinpath(path) 104 | 105 | async def get(self, path: str, headers: dict = {}) -> Any: 106 | try: 107 | response = await self._http_session.get( 108 | url=self.url(path), 109 | timeout=self._timeout, 110 | headers=headers, 111 | ssl=self._ssl, 112 | ) 113 | except aiohttp.ClientConnectionError as e: 114 | raise PowerwallUnreachableError(str(e)) 115 | 116 | return await self._process_response(response) 117 | 118 | async def post( 119 | self, 120 | path: str, 121 | payload: dict, 122 | headers: dict = {}, 123 | ) -> Any: 124 | try: 125 | response = await self._http_session.post( 126 | url=self.url(path), 127 | json=payload, 128 | timeout=self._timeout, 129 | headers=headers, 130 | ssl=self._ssl, 131 | ) 132 | except aiohttp.ClientConnectionError as e: 133 | raise PowerwallUnreachableError(str(e)) 134 | 135 | return await self._process_response(response) 136 | 137 | def is_authenticated(self) -> bool: 138 | for cookie in self._http_session.cookie_jar: 139 | if "AuthCookie" == cookie.key: 140 | return True 141 | return False 142 | 143 | async def login( 144 | self, 145 | username: str, 146 | email: str, 147 | password: str, 148 | force_sm_off: bool = False, 149 | ) -> dict: 150 | # force_sm_off is referred to as 'shouldForceLogin' in the web source code 151 | return await self.post( 152 | "login/Basic", 153 | { 154 | "username": username, 155 | "email": email, 156 | "password": password, 157 | "force_sm_off": force_sm_off, 158 | }, 159 | ) 160 | 161 | async def logout(self) -> None: 162 | if not self.is_authenticated(): 163 | raise ApiError("Must be logged in to log out") 164 | # The api unsets the auth cookie and the token is invalidated 165 | await self.get("logout") 166 | 167 | async def close(self) -> None: 168 | if self._owns_http_session: 169 | await self._http_session.close() 170 | 171 | async def __aenter__(self) -> "API": 172 | return self 173 | 174 | async def __aexit__( 175 | self, 176 | exc_type: Optional[Type[BaseException]], 177 | exc_val: Optional[BaseException], 178 | exc_tb: Optional[TracebackType], 179 | ) -> None: 180 | await self.close() 181 | 182 | # Endpoints are mapped to one method by _ so they can be easily accessed 183 | 184 | async def get_system_status(self) -> dict: 185 | return await self.get("system_status") 186 | 187 | async def get_system_status_soe(self) -> dict: 188 | return await self.get("system_status/soe") 189 | 190 | async def get_meters_aggregates(self) -> dict: 191 | return await self.get("meters/aggregates") 192 | 193 | async def get_sitemaster_run(self): 194 | return await self.get("sitemaster/run") 195 | 196 | async def get_sitemaster_stop(self): 197 | return await self.get("sitemaster/stop") 198 | 199 | async def get_sitemaster(self) -> dict: 200 | return await self.get("sitemaster") 201 | 202 | async def get_status(self) -> dict: 203 | return await self.get("status") 204 | 205 | async def get_customer_registration(self) -> dict: 206 | return await self.get("customer/registration") 207 | 208 | async def get_powerwalls(self): 209 | return await self.get("powerwalls") 210 | 211 | async def get_operation(self) -> dict: 212 | return await self.get("operation") 213 | 214 | async def get_networks(self) -> list: 215 | return await self.get("networks") 216 | 217 | async def get_phase_usage(self): 218 | return await self.get("powerwalls/phase_usages") 219 | 220 | async def post_sitemaster_run_for_commissioning(self): 221 | return await self.post("sitemaster/run_for_commissioning", payload={}) 222 | 223 | async def get_solars(self): 224 | return await self.get("solars") 225 | 226 | async def get_config(self): 227 | return await self.get("config") 228 | 229 | async def get_logs(self): 230 | return await self.get("getlogs") 231 | 232 | async def get_meters(self) -> list: 233 | return await self.get("meters") 234 | 235 | async def get_meters_site(self) -> list: 236 | return await self.get("meters/site") 237 | 238 | async def get_meters_solar(self) -> list: 239 | return await self.get("meters/solar") 240 | 241 | async def get_installer(self) -> dict: 242 | return await self.get("installer") 243 | 244 | async def get_solar_brands(self) -> List[str]: 245 | return await self.get("solars/brands") 246 | 247 | async def get_system_update_status(self) -> dict: 248 | return await self.get("system/update/status") 249 | 250 | async def get_system_status_grid_status(self) -> dict: 251 | return await self.get("system_status/grid_status") 252 | 253 | async def get_site_info(self) -> dict: 254 | return await self.get("site_info") 255 | 256 | async def get_site_info_grid_codes(self) -> list: 257 | return await self.get("site_info/grid_codes") 258 | 259 | async def post_site_info_site_name(self, body: dict) -> dict: 260 | return await self.post("site_info/site_name", body) 261 | 262 | async def post_islanding_mode(self, body: dict) -> dict: 263 | return await self.post("v2/islanding/mode", body) 264 | -------------------------------------------------------------------------------- /tesla_powerwall/responses.py: -------------------------------------------------------------------------------- 1 | import re 2 | from dataclasses import dataclass 3 | from datetime import datetime, timedelta 4 | from typing import Any, Dict, List, Optional 5 | 6 | from .const import ( 7 | DEFAULT_KW_ROUND_PERSICION, 8 | DeviceType, 9 | GridState, 10 | MeterType, 11 | Roles, 12 | ) 13 | from .error import MeterNotAvailableError 14 | from .helpers import convert_to_kw 15 | 16 | 17 | @dataclass 18 | class ResponseBase: 19 | _raw: dict 20 | 21 | def __repr__(self) -> str: 22 | return str(self._raw) 23 | 24 | 25 | @dataclass 26 | class MeterResponse(ResponseBase): 27 | meter: MeterType 28 | instant_power: float 29 | last_communication_time: str 30 | frequency: float 31 | energy_exported: float 32 | energy_imported: float 33 | instant_total_current: float 34 | instant_average_voltage: float 35 | 36 | @staticmethod 37 | def from_dict(meter: MeterType, src: dict) -> "MeterResponse": 38 | return MeterResponse( 39 | src, 40 | meter=meter, 41 | instant_power=src["instant_power"], 42 | last_communication_time=src["last_communication_time"], 43 | frequency=src["frequency"], 44 | energy_exported=src["energy_exported"], 45 | energy_imported=src["energy_imported"], 46 | instant_total_current=src["instant_total_current"], 47 | instant_average_voltage=src["instant_average_voltage"], 48 | ) 49 | 50 | def get_energy_exported(self, precision=DEFAULT_KW_ROUND_PERSICION) -> float: 51 | return convert_to_kw(self.energy_exported, precision) 52 | 53 | def get_energy_imported(self, precision=DEFAULT_KW_ROUND_PERSICION) -> float: 54 | return convert_to_kw(self.energy_imported, precision) 55 | 56 | def get_instant_total_current(self, precision=DEFAULT_KW_ROUND_PERSICION) -> float: 57 | return round(self.instant_total_current, precision) 58 | 59 | def get_power(self, precision=DEFAULT_KW_ROUND_PERSICION) -> float: 60 | return convert_to_kw(self.instant_power, precision) 61 | 62 | def is_active(self, precision=DEFAULT_KW_ROUND_PERSICION) -> bool: 63 | return self.get_power(precision) != 0 64 | 65 | def is_drawing_from(self, precision=DEFAULT_KW_ROUND_PERSICION) -> bool: 66 | if self.meter == MeterType.LOAD: 67 | # Cannot draw from load 68 | return False 69 | else: 70 | return self.get_power(precision) > 0 71 | 72 | def is_sending_to(self, precision=DEFAULT_KW_ROUND_PERSICION) -> bool: 73 | if self.meter == MeterType.LOAD: 74 | # For load the power is always positiv 75 | return self.get_power(precision) > 0 76 | else: 77 | return self.get_power(precision) < 0 78 | 79 | 80 | @dataclass 81 | class MeterDetailsReadings(MeterResponse): 82 | real_power_a: Optional[float] 83 | real_power_b: Optional[float] 84 | real_power_c: Optional[float] 85 | 86 | i_a_current: Optional[float] 87 | i_b_current: Optional[float] 88 | i_c_current: Optional[float] 89 | 90 | v_l1n: Optional[float] 91 | v_l2n: Optional[float] 92 | v_l3n: Optional[float] 93 | 94 | @staticmethod 95 | def from_dict(meter: MeterType, src: dict) -> "MeterDetailsReadings": 96 | meter_response = MeterResponse.from_dict(meter, src) 97 | return MeterDetailsReadings( 98 | real_power_a=src.get("real_power_a"), 99 | real_power_b=src.get("real_power_b"), 100 | real_power_c=src.get("real_power_c"), 101 | i_a_current=src.get("i_a_current"), 102 | i_b_current=src.get("i_b_current"), 103 | i_c_current=src.get("i_c_current"), 104 | v_l1n=src.get("v_l1n"), 105 | v_l2n=src.get("v_l2n"), 106 | v_l3n=src.get("v_l3n"), 107 | # Populate with the values from the base class 108 | **meter_response.__dict__, 109 | ) 110 | 111 | 112 | @dataclass 113 | class MeterDetailsResponse(ResponseBase): 114 | location: MeterType 115 | readings: MeterDetailsReadings 116 | 117 | @staticmethod 118 | def from_dict(src: dict) -> "MeterDetailsResponse": 119 | location = MeterType(src["location"]) 120 | readings = MeterDetailsReadings.from_dict(location, src["Cached_readings"]) 121 | return MeterDetailsResponse(src, location=location, readings=readings) 122 | 123 | 124 | class MetersAggregatesResponse(ResponseBase): 125 | @staticmethod 126 | def from_dict(src: dict) -> "MetersAggregatesResponse": 127 | meters = { 128 | MeterType(key): MeterResponse.from_dict(MeterType(key), value) 129 | for key, value in src.items() 130 | } 131 | return MetersAggregatesResponse(src, meters) 132 | 133 | def __init__(self, response: dict, meters: Dict[MeterType, MeterResponse]) -> None: 134 | self._raw = response 135 | self.meters = meters 136 | 137 | def __getattribute__(self, attr) -> Any: 138 | if attr.upper() in MeterType.__dict__: 139 | m = MeterType(attr) 140 | if m in self.meters: 141 | return self.meters[m] 142 | else: 143 | raise MeterNotAvailableError(m, list(self.meters.keys())) 144 | else: 145 | return object.__getattribute__(self, attr) 146 | 147 | def get_meter(self, meter: MeterType) -> Optional[MeterResponse]: 148 | return self.meters.get(meter) 149 | 150 | 151 | @dataclass 152 | class SiteMasterResponse(ResponseBase): 153 | status: str 154 | is_running: bool 155 | is_connected_to_tesla: bool 156 | is_power_supply_mode: bool 157 | 158 | @staticmethod 159 | def from_dict(src: dict) -> "SiteMasterResponse": 160 | return SiteMasterResponse( 161 | src, 162 | status=src["status"], 163 | is_running=src["running"], 164 | is_connected_to_tesla=src["connected_to_tesla"], 165 | is_power_supply_mode=src["power_supply_mode"], 166 | ) 167 | 168 | 169 | @dataclass 170 | class SiteInfoResponse(ResponseBase): 171 | nominal_system_energy: int 172 | nominal_system_power: int 173 | site_name: str 174 | timezone: str 175 | 176 | @staticmethod 177 | def from_dict(src: dict) -> "SiteInfoResponse": 178 | return SiteInfoResponse( 179 | src, 180 | nominal_system_energy=src["nominal_system_energy_kWh"], 181 | nominal_system_power=src["nominal_system_power_kW"], 182 | site_name=src["site_name"], 183 | timezone=src["timezone"], 184 | ) 185 | 186 | 187 | @dataclass 188 | class PowerwallStatusResponse(ResponseBase): 189 | start_time: datetime 190 | up_time_seconds: timedelta 191 | version: str 192 | device_type: DeviceType 193 | commission_count: int 194 | sync_type: str 195 | git_hash: str 196 | 197 | _START_TIME_FORMAT = "%Y-%m-%d %H:%M:%S %z" 198 | _UP_TIME_SECONDS_REGEX = re.compile( 199 | r"^((?P[\.\d]+?)d)?((?P[\.\d]+?)h)?((?P[\.\d]+?)m)?((?P[\.\d]+?)s)?$" 200 | ) 201 | 202 | @staticmethod 203 | def _parse_uptime_seconds(up_time_seconds: str) -> timedelta: 204 | match = PowerwallStatusResponse._UP_TIME_SECONDS_REGEX.match(up_time_seconds) 205 | if not match: 206 | raise ValueError( 207 | "Unable to parse up time seconds {}".format(up_time_seconds) 208 | ) 209 | 210 | time_params = {} 211 | for name, param in match.groupdict().items(): 212 | if param: 213 | time_params[name] = float(param) 214 | 215 | return timedelta(**time_params) 216 | 217 | @staticmethod 218 | def from_dict(src: dict) -> "PowerwallStatusResponse": 219 | start_time = datetime.strptime( 220 | src["start_time"], PowerwallStatusResponse._START_TIME_FORMAT 221 | ) 222 | up_time_seconds = PowerwallStatusResponse._parse_uptime_seconds( 223 | src["up_time_seconds"] 224 | ) 225 | return PowerwallStatusResponse( 226 | src, 227 | start_time=start_time, 228 | up_time_seconds=up_time_seconds, 229 | version=src["version"], 230 | device_type=DeviceType(src["device_type"]), 231 | commission_count=src["commission_count"], 232 | sync_type=src["sync_type"], 233 | git_hash=src["git_hash"], 234 | ) 235 | 236 | 237 | @dataclass 238 | class LoginResponse(ResponseBase): 239 | firstname: str 240 | lastname: str 241 | token: str 242 | roles: List[Roles] 243 | login_time: str 244 | 245 | @staticmethod 246 | def from_dict(src: dict) -> "LoginResponse": 247 | return LoginResponse( 248 | src, 249 | firstname=src["firstname"], 250 | lastname=src["lastname"], 251 | token=src["token"], 252 | roles=[Roles(role) for role in src["roles"]], 253 | login_time=src["loginTime"], 254 | ) 255 | 256 | 257 | @dataclass 258 | class SolarResponse(ResponseBase): 259 | brand: str 260 | model: str 261 | power_rating_watts: int 262 | 263 | @staticmethod 264 | def from_dict(src: dict) -> "SolarResponse": 265 | return SolarResponse( 266 | src, 267 | brand=src["brand"], 268 | model=src["model"], 269 | power_rating_watts=src["power_rating_watts"], 270 | ) 271 | 272 | 273 | @dataclass 274 | class BatteryResponse(ResponseBase): 275 | """ 276 | A battery pack as part of the system_status response. 277 | """ 278 | 279 | part_number: str 280 | serial_number: str 281 | wobble_detected: bool 282 | energy_remaining: int 283 | capacity: int 284 | # Values might be None if this battery is in GridState.DISABLED 285 | energy_charged: Optional[int] 286 | energy_discharged: Optional[int] 287 | p_out: Optional[int] 288 | q_out: Optional[int] 289 | v_out: Optional[float] 290 | f_out: Optional[float] 291 | i_out: Optional[float] 292 | grid_state: GridState 293 | disabled_reasons: List[str] 294 | 295 | @staticmethod 296 | def from_dict(src: dict) -> "BatteryResponse": 297 | # Check if the battery is disabled. A battery is considered disabled if: 298 | # - there is at least one disabled reason present in the response, 299 | # - or the pinv_grid_state is empty 300 | disabled_reasons = src["disabled_reasons"] 301 | raw_grid_state = src["pinv_grid_state"] 302 | grid_state = ( 303 | GridState.DISABLED 304 | if len(disabled_reasons) > 0 or len(raw_grid_state) == 0 305 | else GridState(raw_grid_state) 306 | ) 307 | return BatteryResponse( 308 | src, 309 | part_number=src["PackagePartNumber"], 310 | serial_number=src["PackageSerialNumber"], 311 | energy_charged=src["energy_charged"], 312 | energy_discharged=src["energy_discharged"], 313 | energy_remaining=src["nominal_energy_remaining"], 314 | capacity=src["nominal_full_pack_energy"], 315 | wobble_detected=src["wobble_detected"], 316 | p_out=src["p_out"], 317 | q_out=src["q_out"], 318 | v_out=src["v_out"], 319 | f_out=src["f_out"], 320 | i_out=src["i_out"], 321 | grid_state=grid_state, 322 | disabled_reasons=disabled_reasons, 323 | ) 324 | -------------------------------------------------------------------------------- /tests/unit/test_powerwall.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import unittest 4 | from typing import Optional, Union 5 | 6 | import aiohttp 7 | import aresponses 8 | 9 | from tesla_powerwall import ( 10 | API, 11 | DeviceType, 12 | GridState, 13 | GridStatus, 14 | IslandMode, 15 | MeterDetailsReadings, 16 | MeterDetailsResponse, 17 | MeterNotAvailableError, 18 | MeterResponse, 19 | MetersAggregatesResponse, 20 | MeterType, 21 | MissingAttributeError, 22 | OperationMode, 23 | Powerwall, 24 | SiteMasterResponse, 25 | assert_attribute, 26 | convert_to_kw, 27 | ) 28 | from tests.unit import ( 29 | ENDPOINT, 30 | ENDPOINT_HOST, 31 | ENDPOINT_PATH, 32 | GRID_STATUS_RESPONSE, 33 | ISLANDING_MODE_OFFGRID_RESPONSE, 34 | ISLANDING_MODE_ONGRID_RESPONSE, 35 | METER_SITE_RESPONSE, 36 | METER_SOLAR_RESPONSE, 37 | METERS_AGGREGATES_RESPONSE, 38 | OPERATION_RESPONSE, 39 | POWERWALLS_RESPONSE, 40 | SITE_INFO_RESPONSE, 41 | SITEMASTER_RESPONSE, 42 | STATUS_RESPONSE, 43 | SYSTEM_STATUS_RESPONSE, 44 | ) 45 | 46 | 47 | class TestPowerWall(unittest.IsolatedAsyncioTestCase): 48 | async def asyncSetUp(self): 49 | self.aresponses = aresponses.ResponsesMockServer() 50 | await self.aresponses.__aenter__() 51 | 52 | self.powerwall = Powerwall(ENDPOINT) 53 | 54 | async def asyncTearDown(self): 55 | await self.powerwall.close() 56 | await self.aresponses.__aexit__(None, None, None) 57 | 58 | def test_get_api(self): 59 | self.assertIsInstance(self.powerwall.get_api(), API) 60 | 61 | def add_response( 62 | self, 63 | path: str, 64 | method: str = "GET", 65 | content_type: str = "application/json", 66 | body: Optional[Union[str, dict]] = None, 67 | ): 68 | self.aresponses.add( 69 | ENDPOINT_HOST, 70 | f"{ENDPOINT_PATH}{path}", 71 | method, 72 | self.aresponses.Response( 73 | headers={"Content-Type": content_type}, 74 | text=json.dumps(body), 75 | ), 76 | ) 77 | 78 | async def test_get_charge(self): 79 | self.add_response("system_status/soe", body={"percentage": 53.123423}) 80 | self.assertEqual(await self.powerwall.get_charge(), 53.123423) 81 | self.aresponses.assert_plan_strictly_followed() 82 | 83 | async def test_get_sitemaster(self): 84 | self.add_response("sitemaster", body=SITEMASTER_RESPONSE) 85 | 86 | sitemaster = await self.powerwall.get_sitemaster() 87 | self.assertIsInstance(sitemaster, SiteMasterResponse) 88 | 89 | self.assertEqual(sitemaster.status, "StatusUp") 90 | self.assertEqual(sitemaster.is_running, True) 91 | self.assertEqual(sitemaster.is_connected_to_tesla, True) 92 | self.assertEqual(sitemaster.is_power_supply_mode, False) 93 | self.aresponses.assert_plan_strictly_followed() 94 | 95 | async def test_get_meters(self): 96 | self.add_response("meters/aggregates", body=METERS_AGGREGATES_RESPONSE) 97 | meters = await self.powerwall.get_meters() 98 | self.assertIsInstance(meters, MetersAggregatesResponse) 99 | self.assertListEqual( 100 | list(meters.meters.keys()), 101 | [ 102 | MeterType.BATTERY, 103 | MeterType.LOAD, 104 | MeterType.SITE, 105 | MeterType.SOLAR, 106 | ], 107 | ) 108 | self.assertIsInstance(meters.load, MeterResponse) 109 | self.assertIsInstance(meters.get_meter(MeterType.LOAD), MeterResponse) 110 | self.assertIsNone(meters.get_meter(MeterType.GENERATOR)) 111 | with self.assertRaises(MeterNotAvailableError): 112 | meters.generator 113 | self.aresponses.assert_plan_strictly_followed() 114 | 115 | async def test_get_meter_site(self): 116 | self.add_response("meters/site", body=METER_SITE_RESPONSE) 117 | meter = await self.powerwall.get_meter_site() 118 | self.assertIsInstance(meter, MeterDetailsResponse) 119 | self.assertEqual(meter.location, MeterType.SITE) 120 | readings = meter.readings 121 | self.assertIsInstance(readings, MeterDetailsReadings) 122 | # Optional voltage fields 123 | self.assertIsInstance(readings.v_l1n, float) 124 | self.assertIsInstance(readings.v_l2n, float) 125 | self.assertIsNone(readings.v_l3n) 126 | 127 | self.assertEqual(readings.instant_power, -18.00000076368451) 128 | self.assertEqual(readings.get_power(), -0.0) 129 | self.aresponses.assert_plan_strictly_followed() 130 | 131 | async def test_get_meter_solar(self): 132 | self.add_response("meters/solar", body=METER_SOLAR_RESPONSE) 133 | meter = await self.powerwall.get_meter_solar() 134 | self.assertIsInstance(meter, MeterDetailsResponse) 135 | self.assertEqual(meter.location, MeterType.SOLAR) 136 | readings = meter.readings 137 | self.assertIsInstance(readings, MeterDetailsReadings) 138 | # Optional voltage fields 139 | self.assertIsInstance(readings.v_l1n, float) 140 | self.assertIsNone(readings.v_l2n) 141 | self.assertIsNone(readings.v_l3n) 142 | self.aresponses.assert_plan_strictly_followed() 143 | 144 | async def test_is_sending(self): 145 | self.add_response("meters/aggregates", body=METERS_AGGREGATES_RESPONSE) 146 | meters = await self.powerwall.get_meters() 147 | self.assertEqual(meters.get_meter(MeterType.SOLAR).is_sending_to(), False) 148 | self.assertEqual(meters.get_meter(MeterType.SOLAR).is_active(), True) 149 | self.assertEqual(meters.get_meter(MeterType.SOLAR).is_drawing_from(), True) 150 | self.assertEqual(meters.get_meter(MeterType.SITE).is_sending_to(), True) 151 | self.assertEqual(meters.get_meter(MeterType.LOAD).is_sending_to(), True) 152 | self.assertEqual(meters.get_meter(MeterType.LOAD).is_drawing_from(), False) 153 | self.assertEqual(meters.get_meter(MeterType.LOAD).is_active(), True) 154 | self.aresponses.assert_plan_strictly_followed() 155 | 156 | async def test_get_grid_status(self): 157 | self.add_response("system_status/grid_status", body=GRID_STATUS_RESPONSE) 158 | grid_status = await self.powerwall.get_grid_status() 159 | self.assertEqual(grid_status, GridStatus.CONNECTED) 160 | self.aresponses.assert_plan_strictly_followed() 161 | 162 | async def test_is_grid_services_active(self): 163 | self.add_response("system_status/grid_status", body=GRID_STATUS_RESPONSE) 164 | self.assertEqual(await self.powerwall.is_grid_services_active(), False) 165 | self.aresponses.assert_plan_strictly_followed() 166 | 167 | async def test_get_site_info(self): 168 | self.add_response("site_info", body=SITE_INFO_RESPONSE) 169 | site_info = await self.powerwall.get_site_info() 170 | self.assertEqual(site_info.nominal_system_energy, 27) 171 | self.assertEqual(site_info.site_name, "test") 172 | self.assertEqual(site_info.timezone, "Europe/Berlin") 173 | self.aresponses.assert_plan_strictly_followed() 174 | 175 | async def test_get_status(self): 176 | self.add_response("status", body=STATUS_RESPONSE) 177 | status = await self.powerwall.get_status() 178 | self.assertEqual( 179 | status.up_time_seconds, 180 | datetime.timedelta(seconds=61891, microseconds=214751), 181 | ) 182 | self.assertEqual( 183 | status.start_time, 184 | datetime.datetime( 185 | 2020, 186 | 10, 187 | 28, 188 | 20, 189 | 14, 190 | 11, 191 | tzinfo=datetime.timezone(datetime.timedelta(seconds=28800)), 192 | ), 193 | ) 194 | self.assertEqual(status.device_type, DeviceType.GW1) 195 | self.assertEqual(status.version, "1.50.1 c58c2df3") 196 | self.aresponses.assert_plan_strictly_followed() 197 | 198 | async def test_get_device_type(self): 199 | self.add_response("status", body=STATUS_RESPONSE) 200 | device_type = await self.powerwall.get_device_type() 201 | self.assertIsInstance(device_type, DeviceType) 202 | self.assertEqual(device_type, DeviceType.GW1) 203 | self.aresponses.assert_plan_strictly_followed() 204 | 205 | async def test_get_serial_numbers(self): 206 | self.add_response("powerwalls", body=POWERWALLS_RESPONSE) 207 | serial_numbers = await self.powerwall.get_serial_numbers() 208 | self.assertEqual(serial_numbers, ["SerialNumber1", "SerialNumber2"]) 209 | self.aresponses.assert_plan_strictly_followed() 210 | 211 | async def test_get_gateway_din(self): 212 | self.add_response("powerwalls", body=POWERWALLS_RESPONSE) 213 | gateway_din = await self.powerwall.get_gateway_din() 214 | self.assertEqual(gateway_din, "gateway_din") 215 | self.aresponses.assert_plan_strictly_followed() 216 | 217 | async def test_get_backup_reserved_percentage(self): 218 | self.add_response("operation", body=OPERATION_RESPONSE) 219 | self.assertEqual( 220 | await self.powerwall.get_backup_reserve_percentage(), 5.000019999999999 221 | ) 222 | self.aresponses.assert_plan_strictly_followed() 223 | 224 | async def test_get_operation_mode(self): 225 | self.add_response("operation", body=OPERATION_RESPONSE) 226 | self.assertEqual( 227 | await self.powerwall.get_operation_mode(), OperationMode.SELF_CONSUMPTION 228 | ) 229 | self.aresponses.assert_plan_strictly_followed() 230 | 231 | async def test_get_version(self): 232 | self.add_response("status", body=STATUS_RESPONSE) 233 | self.assertEqual(await self.powerwall.get_version(), "1.50.1") 234 | self.aresponses.assert_plan_strictly_followed() 235 | 236 | async def test_system_status(self): 237 | self.add_response("system_status", body=SYSTEM_STATUS_RESPONSE) 238 | self.assertEqual(await self.powerwall.get_capacity(), 28078) 239 | 240 | self.add_response("system_status", body=SYSTEM_STATUS_RESPONSE) 241 | self.assertEqual(await self.powerwall.get_energy(), 14807) 242 | 243 | self.add_response("system_status", body=SYSTEM_STATUS_RESPONSE) 244 | batteries = await self.powerwall.get_batteries() 245 | self.assertEqual(len(batteries), 3) 246 | self.assertEqual(batteries[0].part_number, "XXX-G") 247 | self.assertEqual(batteries[0].serial_number, "TGXXX") 248 | self.assertEqual(batteries[0].energy_remaining, 7378) 249 | self.assertEqual(batteries[0].capacity, 14031) 250 | self.assertEqual(batteries[0].energy_charged, 5525740) 251 | self.assertEqual(batteries[0].energy_discharged, 4659550) 252 | self.assertEqual(batteries[0].wobble_detected, False) 253 | self.assertEqual(batteries[0].p_out, -1830) 254 | self.assertEqual(batteries[0].i_out, 39) 255 | self.assertEqual(batteries[0].f_out, 50.067) 256 | self.assertEqual(batteries[0].q_out, 30) 257 | self.assertEqual(batteries[0].v_out, 226.60000000000002) 258 | self.assertEqual(batteries[0].grid_state, GridState.COMPLIANT) 259 | self.assertEqual(batteries[2].grid_state, GridState.DISABLED) 260 | self.assertEqual(batteries[2].p_out, None) 261 | self.assertEqual(batteries[2].i_out, None) 262 | self.assertEqual(batteries[2].energy_charged, None) 263 | self.assertEqual( 264 | batteries[2].disabled_reasons, ["DisabledExcessiveVoltageDrop"] 265 | ) 266 | self.aresponses.assert_plan_strictly_followed() 267 | 268 | async def test_islanding_mode_offgrid(self): 269 | self.add_response( 270 | "v2/islanding/mode", method="POST", body=ISLANDING_MODE_OFFGRID_RESPONSE 271 | ) 272 | 273 | mode = await self.powerwall.set_island_mode(IslandMode.OFFGRID) 274 | self.assertEqual(mode, IslandMode.OFFGRID) 275 | self.aresponses.assert_plan_strictly_followed() 276 | 277 | async def test_islanding_mode_ongrid(self): 278 | self.add_response( 279 | "v2/islanding/mode", method="POST", body=ISLANDING_MODE_ONGRID_RESPONSE 280 | ) 281 | 282 | mode = await self.powerwall.set_island_mode(IslandMode.ONGRID) 283 | self.assertEqual(mode, IslandMode.ONGRID) 284 | self.aresponses.assert_plan_strictly_followed() 285 | 286 | def test_helpers(self): 287 | resp = {"a": 1} 288 | with self.assertRaises(MissingAttributeError): 289 | assert_attribute(resp, "test") 290 | 291 | with self.assertRaises(MissingAttributeError): 292 | assert_attribute(resp, "test", "test") 293 | 294 | self.assertEqual(convert_to_kw(2500, -1), 2.5) 295 | 296 | async def test_close(self): 297 | api_session = None 298 | async with Powerwall(ENDPOINT) as powerwall: 299 | api_session = powerwall._api._http_session 300 | self.assertFalse(api_session.closed) 301 | self.assertTrue(api_session.closed) 302 | 303 | async with aiohttp.ClientSession() as session: 304 | api_session = session 305 | async with Powerwall(ENDPOINT, http_session=session) as powerwall: 306 | self.assertFalse(api_session.closed) 307 | self.assertFalse(api_session.closed) 308 | self.assertTrue(api_session.closed) 309 | 310 | powerwall = Powerwall(ENDPOINT) 311 | api_session = powerwall._api._http_session 312 | self.assertFalse(api_session.closed) 313 | await powerwall.close() 314 | self.assertTrue(api_session.closed) 315 | 316 | async with aiohttp.ClientSession() as session: 317 | api_session = session 318 | powerwall = Powerwall(ENDPOINT, http_session=session) 319 | self.assertFalse(api_session.closed) 320 | await powerwall.close() 321 | self.assertFalse(api_session.closed) 322 | self.assertTrue(api_session.closed) 323 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Licence](https://img.shields.io/github/license/jrester/tesla_powerwall?style=for-the-badge) 2 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/tesla_powerwall?color=blue&style=for-the-badge) 3 | ![PyPI](https://img.shields.io/pypi/v/tesla_powerwall?style=for-the-badge) 4 | 5 | Python Tesla Powerwall API for consuming a local endpoint. 6 | > Note: This is not an official API provided by Tesla and this project is not affilated with Tesla in any way. 7 | 8 | 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. 9 | 10 | # Table of Contents 11 | 12 | - [Installation](#installation) 13 | - [Limitations](#limitations) 14 | - [Adjusting Backup Reserve Percentage](#adjusting-backup-reserve-percentage) 15 | - [Usage](#usage) 16 | - [Setup](#setup) 17 | - [Authentication](#authentication) 18 | - [General](#general) 19 | - [Errors](#errors) 20 | - [Response](#response) 21 | - [Battery level](#battery-level) 22 | - [Capacity](#capacity) 23 | - [Battery Packs](#battery-packs) 24 | - [Powerwall Status](#powerwall-status) 25 | - [Sitemaster](#sitemaster) 26 | - [Siteinfo](#siteinfo) 27 | - [Meters](#meters) 28 | - [Aggregates](#aggregates) 29 | - [Current power supply/draw](#current-power-supplydraw) 30 | - [Energy exported/imported](#energy-exportedimported) 31 | - [Details](#details) 32 | - [Device Type](#device-type) 33 | - [Grid Status](#grid-status) 34 | - [Operation mode](#operation-mode) 35 | - [Powerwalls Serial Numbers](#powerwalls-serial-numbers) 36 | - [Gateway DIN](#gateway-din) 37 | - [VIN](#vin) 38 | - [Off-grid status](#off-grid-status-set-island-mode) 39 | - [Development](#development) 40 | 41 | ## Installation 42 | 43 | Install the library via pip: 44 | 45 | ```bash 46 | $ pip install tesla_powerwall 47 | ``` 48 | 49 | ## Limitations 50 | 51 | ### Adjusting Backup Reserve Percentage 52 | 53 | 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). 54 | However, if you believe there exists a solution, feel free to open an issue detailing the solution. 55 | 56 | ## Usage 57 | 58 | For a basic Overview of the functionality of this library you can take a look at `examples/example.py`. You can run the example, by cloning the repo and executing in your shell: 59 | 60 | ```bash 61 | $ export POWERWALL_IP= 62 | $ export POWERWALL_PASSWORD= 63 | $ tox -e example 64 | ``` 65 | 66 | ### Setup 67 | 68 | ```python 69 | from tesla_powerwall import Powerwall 70 | 71 | # Create a simple powerwall object by providing the IP 72 | powerwall = Powerwall("") 73 | #=> 74 | 75 | # Create a powerwall object with more options 76 | powerwall = Powerwall( 77 | endpoint="", 78 | # Configure timeout; default is 10 79 | timeout=10, 80 | # Provide a requests.Session or None. If None is provided, a Session will be created. 81 | http_session=None, 82 | # Whether to verify the SSL certificate or not 83 | verify_ssl=False 84 | ) 85 | #=> 86 | ``` 87 | 88 | > 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`. 89 | 90 | ### Authentication 91 | 92 | 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. 93 | When you perform a request without being authenticated, an `AccessDeniedError` will be thrown. 94 | 95 | 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: 96 | 97 | ```python 98 | from tesla_powerwall import User 99 | 100 | # Login as customer without email 101 | # The default value for the email is "" 102 | await powerwall.login("") 103 | #=> 104 | 105 | # Login as customer with email 106 | await powerwall.login("", "") 107 | #=> 108 | 109 | # Login with different user 110 | await powerwall.login_as(User.INSTALLER, "", "") 111 | #=> 112 | 113 | # Check if we are logged in 114 | # This method only checks wether a cookie with a Bearer token exists 115 | # It does not verify whether this token is valid 116 | powerwall.is_authenticated() 117 | #=> True 118 | 119 | # Logout 120 | await powerwall.logout() 121 | powerwall.is_authenticated() 122 | #=> False 123 | ``` 124 | 125 | ### General 126 | 127 | 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`: 128 | 129 | ```python 130 | from tesla_powerwall import API 131 | 132 | # Manually create API object 133 | api = API('https:///') 134 | # Perform get on 'system_status/soe' 135 | await api.get_system_status_soe() 136 | #=> {'percentage': 97.59281925744594} 137 | 138 | # From existing powerwall 139 | api = powerwall.get_api() 140 | await api.get_system_status_soe() 141 | ``` 142 | 143 | The `Powerwall` objet provides a wrapper around the API and exposes common methods. 144 | 145 | ### Battery level 146 | 147 | Get charge in percent: 148 | 149 | ```python 150 | await powerwall.get_charge() 151 | #=> 97.59281925744594 (%) 152 | ``` 153 | 154 | Get charge in watt: 155 | 156 | ```python 157 | await powerwall.get_energy() 158 | #=> 14807 (Wh) 159 | ``` 160 | 161 | ### Capacity 162 | 163 | Get the capacity of your powerwall in watt: 164 | 165 | ```python 166 | await powerwall.get_capacity() 167 | #=> 28078 (Wh) 168 | ``` 169 | 170 | ### Battery Packs 171 | 172 | Get information about the battery packs that are installed: 173 | 174 | Assuming that the battery is operational, you can retrive a number of values about each battery: 175 | ```python 176 | batteries = await powerwall.get_batteries() 177 | #=> [, ] 178 | batteries[0].part_number 179 | #=> "XXX-G" 180 | batteries[0].serial_number 181 | #=> "TGXXX" 182 | batteries[0].energy_remaining 183 | #=> 7378 (Wh) 184 | batteries[0].capacity 185 | #=> 14031 (Wh) 186 | batteries[0].energy_charged 187 | #=> 5525740 (Wh) 188 | batteries[0].energy_discharged 189 | #=> 4659550 (Wh) 190 | batteries[0].wobble_detected 191 | #=> False 192 | batteries[0].p_out 193 | #=> 260 194 | batteries[0].q_out 195 | #=> -1080 196 | batteries[0].v_out 197 | #=> 245.70 198 | batteries[0].f_out 199 | #=> 49.953 200 | batteries[0].i_out 201 | #=> -7.4 202 | batteries[0].grid_state 203 | #=> GridState.COMPLIANT 204 | batteries[0].disabled_reasons 205 | #=> [] 206 | 207 | ``` 208 | 209 | If a battery is disabled it's `grid_state` will be `GridState.DISABLED` and some values will be `None`. The variable `disabled_reasons` might contain more information why the battery is disabled: 210 | ```python 211 | ... 212 | batteries[1].grid_state 213 | #=> GridState.DISABLED 214 | batteries[1].disabled_reasons 215 | #=> ["DisabledExcessiveVoltageDrop"] 216 | batteries[1].p_out 217 | #=> None 218 | batteries[1].energy_charged 219 | #=> None 220 | ``` 221 | 222 | ### Powerwall Status 223 | 224 | ```python 225 | status = await powerwall.get_status() 226 | #=> 227 | status.version 228 | #=> '1.49.0' 229 | status.up_time_seconds 230 | #=> datetime.timedelta(days=13, seconds=63287, microseconds=146455) 231 | status.start_time 232 | #=> datetime.datetime(2020, 9, 23, 23, 31, 16, tzinfo=datetime.timezone(datetime.timedelta(seconds=28800))) 233 | status.device_type 234 | #=> DeviceType.GW2 235 | ``` 236 | 237 | ### Sitemaster 238 | 239 | ```python 240 | sm = await powerwall.get_sitemaster() 241 | #=> 242 | sm.status 243 | #=> StatusUp 244 | sm.running 245 | #=> true 246 | sm.connected_to_tesla 247 | #=> true 248 | ``` 249 | 250 | The sitemaster can be started and stopped using `run()` and `stop()` 251 | 252 | ### Siteinfo 253 | 254 | ```python 255 | info = await powerwall.get_site_info() 256 | #=> 257 | info.site_name 258 | #=> 'Tesla Home' 259 | info.country 260 | #=> 'Germany' 261 | info.nominal_system_energy 262 | #=> 13.5 (kWh) 263 | info.timezone 264 | #=> 'Europe/Berlin' 265 | ``` 266 | 267 | ### Meters 268 | 269 | #### Aggregates 270 | 271 | ```python 272 | from tesla_powerwall import MeterType 273 | 274 | meters = await powerwall.get_meters() 275 | #=> 276 | 277 | # access meter, but may return None when meter is not available 278 | meters.get_meter(MeterType.SOLAR) 279 | #=> 280 | 281 | # access meter, but may raise MeterNotAvailableError when the meter is not available at your powerwall (e.g. no solar panels installed) 282 | meters.solar 283 | #=> 284 | 285 | # get all available meters at the current powerwall 286 | meters.meters.keys() 287 | #=> [, , , ] 288 | ``` 289 | 290 | 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. 291 | 292 | #### Current power supply/draw 293 | 294 | `Meter` provides different methods for checking current power supply/draw: 295 | 296 | ```python 297 | meters = await powerwall.get_meters() 298 | meters.solar.get_power() 299 | #=> 0.4 (kW) 300 | meters.solar.instant_power 301 | #=> 409.941801071167 (W) 302 | meters.solar.is_drawing_from() 303 | #=> True 304 | meters.load.is_sending_to() 305 | #=> True 306 | meters.battery.is_active() 307 | #=> False 308 | 309 | # Different precision settings might return different results 310 | meters.battery.is_active(precision=5) 311 | #=> True 312 | ``` 313 | 314 | > Note: For MeterType.LOAD `is_drawing_from` **always** returns `False` because it cannot be drawn from `load`. 315 | 316 | #### Energy exported/imported 317 | 318 | 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`: 319 | 320 | ```python 321 | meters.battery.energy_exported 322 | #=> 6394100 (Wh) 323 | meters.battery.get_energy_exported() 324 | #=> 6394.1 (kWh) 325 | meters.battery.energy_imported 326 | #=> 7576570 (Wh) 327 | meters.battery.get_energy_imported() 328 | #=> 7576.6 (kWh) 329 | ``` 330 | 331 | ### Details 332 | 333 | You can receive more detailed information about the meters `site` and `solar`: 334 | 335 | ```python 336 | meter_details = await powerwall.get_meter_site() # or get_meter_solar() for the solar meter 337 | #=> 338 | readings = meter_details.readings 339 | #=> 340 | readings.real_power_a # same for real_power_b and real_power_c 341 | #=> 619.13532458 342 | readings.i_a_current # same for i_b_current and i_c_current 343 | #=> 3.02 344 | readings.v_l1n # smae for v_l2n and v_l3n 345 | #=> 235.82 346 | readings.instant_power 347 | #=> -18.000023458 348 | readings.is_sending() 349 | ``` 350 | 351 | As `MeterDetailsReadings` inherits from `MeterResponse` (which is used in `MetersAggratesResponse`) it exposes the same data and methods. 352 | 353 | > For the meters battery and grid no additional details are provided, therefore no methods exist for those meters 354 | 355 | ### Device Type 356 | 357 | ```python 358 | await powerwall.get_device_type() 359 | #=> 360 | ``` 361 | 362 | ### Grid Status 363 | 364 | Get current grid status. 365 | 366 | ```python 367 | await powerwall.get_grid_status() 368 | #=> 369 | await powerwall.is_grid_services_active() 370 | #=> False 371 | ``` 372 | 373 | ### Operation mode 374 | 375 | ```python 376 | await powerwall.get_operation_mode() 377 | #=> 378 | await powerwall.get_backup_reserve_percentage() 379 | #=> 5.000019999999999 (%) 380 | ``` 381 | 382 | ### Powerwalls Serial Numbers 383 | 384 | ```python 385 | await serials = powerwall.get_serial_numbers() 386 | #=> ["...", "...", ...] 387 | ``` 388 | 389 | ### Gateway DIN 390 | 391 | ```python 392 | await din = powerwall.get_gateway_din() 393 | #=> 4159645-02-A--TGXXX 394 | ``` 395 | 396 | ### VIN 397 | 398 | ```python 399 | await vin = powerwall.get_vin() 400 | ``` 401 | 402 | ### Off-grid status (Set Island mode) 403 | 404 | Take your powerwall on- and off-grid similar to the "Take off-grid" button in the Tesla app. 405 | 406 | #### Set powerwall to off-grid (Islanded) 407 | 408 | ```python 409 | await powerwall.set_island_mode(IslandMode.OFFGRID) 410 | ``` 411 | 412 | #### Set powerwall to off-grid (Connected) 413 | 414 | ```python 415 | await powerwall.set_island_mode(IslandMode.ONGRID) 416 | ``` 417 | 418 | # Development 419 | 420 | ## pre-commit 421 | 422 | This project uses pre-commit to run linters, formatters and type checking. You can easily run those checks locally: 423 | 424 | ```sh 425 | # Install the pre-commit hooks 426 | $ pre-commit install 427 | pre-commit installed at .git/hooks/pre-commit 428 | ``` 429 | 430 | Now those checks will be execute on every `git commit`. You can also execute all checks manually with `pre-commit run --all-files`. 431 | 432 | ## Building 433 | 434 | ```sh 435 | $ python -m build 436 | ``` 437 | 438 | ## Testing 439 | 440 | The tests are split in unit and integration tests. 441 | 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. 442 | 443 | ### Unit-Tests 444 | 445 | To run unit tests use tox: 446 | 447 | ```sh 448 | $ tox -e unit 449 | ``` 450 | 451 | ### Integration-Tests 452 | 453 | To execute the integration tests you need to first provide some information about your powerwall: 454 | 455 | ```sh 456 | $ export POWERWALL_IP= 457 | $ export POWERWALL_PASSWORD= 458 | $ tox -e integration 459 | ``` 460 | 461 | > 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! 462 | --------------------------------------------------------------------------------