├── tests ├── __init__.py ├── fixtures │ ├── token.json │ ├── chargers.json │ ├── sites.json │ ├── site.json │ ├── charger-state.json │ └── site-state.json ├── test_basedict.py ├── test_client.py └── test_charger.py ├── .github ├── FUNDING.yml └── workflows │ └── python-app.yml ├── setup.cfg ├── pyeasee ├── __init__.py ├── exceptions.py ├── throttler.py ├── utils.py ├── site.py ├── __main__.py ├── easee.py ├── const.py └── charger.py ├── Makefile ├── setup.py ├── examples ├── user_consumption.py └── site_consumption.py ├── requirements-dev.txt ├── LICENSE ├── .gitignore ├── README.md └── html └── pyeasee ├── index.html ├── utils.html ├── exceptions.html ├── easee.html └── site.html /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [nordicopen] 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.8.15 3 | 4 | [flake8] 5 | max-line-length = 120 6 | 7 | [isort] 8 | profile = black 9 | force_sort_within_sections = true 10 | -------------------------------------------------------------------------------- /tests/fixtures/token.json: -------------------------------------------------------------------------------- 1 | { 2 | "accessToken": "sometoken1234", 3 | "expiresIn": 86400.0, 4 | "accessClaims": [ 5 | "User" 6 | ], 7 | "tokenType": "Bearer", 8 | "refreshToken": "arefreshtoken1234" 9 | } 10 | -------------------------------------------------------------------------------- /pyeasee/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Easee charger API library 3 | """ 4 | from .charger import * # noqa: 5 | from .const import * # noqa: 6 | from .easee import * # noqa: 7 | from .easee import __VERSION__ as __version__ # noqa: 8 | from .site import * # noqa: 9 | from .throttler import * # noqa: 10 | from .utils import * # noqa: 11 | -------------------------------------------------------------------------------- /tests/fixtures/chargers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "EH12345", 4 | "name": "Easee Home 12345", 5 | "color": 1, 6 | "createdOn": "2020-07-18T19:12:53.385Z", 7 | "updatedOn": "2020-07-18T19:12:53.385Z", 8 | "backPlate": { 9 | "id": "backPlateId", 10 | "masterBackPlateId": "masterBackPlateId" 11 | }, 12 | "levelOfAccess": 1, 13 | "productCode": 1, 14 | "userRole": 1 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /tests/fixtures/sites.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 55555, 4 | "siteKey": "WAFE-1234", 5 | "name": "Site name", 6 | "levelOfAccess": 1, 7 | "address": { 8 | "street": "street", 9 | "buildingNumber": "buildingNumber", 10 | "zip": "zip", 11 | "area": "area", 12 | "country": { 13 | "id": "countryid", 14 | "name": "countryname", 15 | "phonePrefix": 46 16 | }, 17 | "latitude": 0, 18 | "longitude": 0, 19 | "altitude": 0 20 | } 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | clean: 2 | rm -rf pyeasee.egg-info dist build 3 | 4 | lint: 5 | isort pyeasee 6 | black pyeasee --line-length 120 7 | flake8 --ignore=E501,E231,F403 pyeasee 8 | 9 | install_dev: 10 | pip install -r requirements-dev.txt 11 | 12 | test: 13 | pytest -s -v 14 | 15 | bump: 16 | bump2version --allow-dirty patch setup.py pyeasee/easee.py 17 | 18 | doc: 19 | rm -rf html 20 | pdoc --html --config show_source_code=False pyeasee 21 | 22 | publish_docs: doc 23 | git subtree push --prefix html origin gh-pages 24 | # git push origin `git subtree split --prefix html master`:gh-pages --force 25 | 26 | build: clean 27 | python setup.py sdist bdist_wheel 28 | 29 | publish-test: 30 | twine upload --repository testpypi dist/* 31 | 32 | publish: build publish_docs 33 | twine upload dist/* 34 | -------------------------------------------------------------------------------- /tests/test_basedict.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import datetime 3 | from pyeasee import BaseDict 4 | 5 | 6 | def test_parse_iso_date_without_millis(): 7 | bd = BaseDict({"date": "2020-07-18T07:02:45Z"}) 8 | date = bd.get("date") 9 | assert type(date) == datetime.datetime 10 | 11 | 12 | def test_parse_iso_date_with_millis(): 13 | bd = BaseDict({"date": "2020-04-06T11:59:53.135015"}) 14 | date = bd.get("date") 15 | assert type(date) == datetime.datetime 16 | 17 | 18 | def test_millis_datestring_defaults_to_utc(): 19 | bd = BaseDict({"date": "2020-04-06T11:59:53.135015"}) 20 | date = bd.get("date") 21 | assert date.tzname() == "UTC" 22 | 23 | 24 | def test_iso_datestring_defaults_to_utc(): 25 | bd = BaseDict({"date": "2020-07-18T07:02:45Z"}) 26 | date = bd.get("date") 27 | assert date.tzname() == "UTC" 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup script for easee""" 2 | 3 | import os.path 4 | from setuptools import setup 5 | 6 | # This call to setup() does all the work 7 | setup( 8 | name="pyeasee", 9 | version="0.8.15", 10 | description="Easee EV charger API library", 11 | long_description=open("README.md").read(), 12 | long_description_content_type="text/markdown", 13 | url="https://github.com/nordicopen/pyeasee", 14 | author="Ola Lidholm", 15 | author_email="olal@plea.se", 16 | license="MIT", 17 | classifiers=[ 18 | "License :: OSI Approved :: MIT License", 19 | "Programming Language :: Python", 20 | "Programming Language :: Python :: 3", 21 | ], 22 | packages=["pyeasee"], 23 | include_package_data=True, 24 | install_requires=["aiohttp", "pysignalr==1.3.0"], 25 | entry_points={"console_scripts": ["pyeasee=pyeasee.__main__:main"]}, 26 | ) 27 | -------------------------------------------------------------------------------- /pyeasee/exceptions.py: -------------------------------------------------------------------------------- 1 | class AuthorizationFailedException(Exception): 2 | pass 3 | 4 | 5 | class NotFoundException(Exception): 6 | pass 7 | 8 | 9 | class TooManyRequestsException(Exception): 10 | pass 11 | 12 | 13 | class ServerFailureException(Exception): 14 | pass 15 | 16 | 17 | class ExceptionWithDict(Exception): 18 | def __init__(self, *args): 19 | if args: 20 | self.message = args[0] 21 | else: 22 | self.message = None 23 | 24 | def __str__(self): 25 | if self.message: 26 | return self.message.get("title", "") 27 | else: 28 | return f"{type(self).__name__} has been raised" 29 | 30 | 31 | class ForbiddenServiceException(ExceptionWithDict): 32 | """Authenticated but access to resource not allowed.""" 33 | 34 | 35 | class BadRequestException(ExceptionWithDict): 36 | """Bad arguments or operation not allowed in this context.""" 37 | -------------------------------------------------------------------------------- /examples/user_consumption.py: -------------------------------------------------------------------------------- 1 | # List consumption per user for all sites assosiated with an account 2 | import asyncio 3 | import sys 4 | 5 | from pyeasee import Easee 6 | 7 | 8 | async def async_main(): 9 | 10 | if len(sys.argv) < 3: 11 | print(f"Usage: {sys.argv[0]} ") 12 | return 13 | 14 | print(f"Logging in using: {sys.argv[1]} {sys.argv[2]}") 15 | easee = Easee(sys.argv[1], sys.argv[2]) 16 | 17 | sites = await easee.get_sites() 18 | for site in sites: 19 | print(f"Site {site.name} ({site.id})") 20 | users = await site.get_users() 21 | for user in users["siteUsers"]: 22 | print(f" User {user['userId']}: {user['name']}, {user['email']}") 23 | consumptions = await site.get_user_monthly_consumption(user["userId"]) 24 | for consumption in consumptions: 25 | print(f" {consumption['year']}-{consumption['month']}: {consumption['totalEnergyUsage']} kWh") 26 | 27 | await easee.close() 28 | 29 | 30 | asyncio.run(async_main()) 31 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.10.2 2 | aioresponses==0.7.6 3 | appdirs==1.4.4 4 | async-timeout==4.0.3 5 | attrs==23.2.0 6 | autoflake==2.3.0 7 | black 8 | bleach==6.1.0 9 | bump2version==1.0.1 10 | certifi==2024.7.4 11 | chardet==5.2.0 12 | click 13 | colorama==0.4.6 14 | docutils==0.20.1 15 | flake8 16 | idna==3.7 17 | importlib-metadata==7.0.2 18 | isort 19 | json-schema-to-class==0.2.4 20 | keyring==24.3.1 21 | lazy-write==0.0.1 22 | Mako==1.3.2 23 | Markdown==3.5.2 24 | MarkupSafe==2.1.5 25 | mccabe==0.7.0 26 | more-itertools==10.2.0 27 | msgpack==1.0.8 28 | multidict==6.0.5 29 | packaging==23.2 30 | pathspec 31 | pdoc3==0.10.0 32 | pkginfo==1.10.0 33 | pluggy==1.4.0 34 | py==1.11.0 35 | pycodestyle==2.11.1 36 | pyflakes==3.2.0 37 | Pygments==2.17.2 38 | pyparsing==3.1.2 39 | pysignalr==1.0.0 40 | pytest==8.0.2 41 | pytest-asyncio==0.23.5 42 | readme-renderer==43.0 43 | regex==2023.12.25 44 | requests==2.32.0 45 | requests-toolbelt==1.0.0 46 | rfc3986==2.0.0 47 | six==1.16.0 48 | toml==0.10.2 49 | tqdm==4.66.3 50 | twine==5.0.0 51 | typed-ast 52 | urllib3==2.2.2 53 | wcwidth==0.2.13 54 | webencodings==0.5.1 55 | yarl==1.9.4 56 | zipp==3.19.1 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Niklas Fondberg 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 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Easee library 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python 3.11 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: 3.11 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install flake8 pytest 27 | if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi 28 | - name: Lint with flake8 29 | run: | 30 | # stop the build if there are Python syntax errors or undefined names 31 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 32 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 33 | # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 34 | - name: Test with pytest 35 | run: | 36 | pytest 37 | -------------------------------------------------------------------------------- /pyeasee/throttler.py: -------------------------------------------------------------------------------- 1 | """ 2 | Throttler for API calls 3 | """ 4 | import asyncio 5 | from collections import deque 6 | import logging 7 | import time 8 | from typing import Deque 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | class Throttler: 14 | def __init__(self, rate_limit: int, period: float = 1.0, name: str = ""): 15 | self.rate_limit = rate_limit 16 | self.period = period 17 | self.name = name 18 | 19 | self._task_logs: Deque[float] = deque() 20 | 21 | def flush(self): 22 | now = time.monotonic() 23 | while self._task_logs: 24 | if now - self._task_logs[0] > self.period: 25 | self._task_logs.popleft() 26 | else: 27 | break 28 | 29 | async def acquire(self): 30 | self.flush() 31 | if len(self._task_logs) >= self.rate_limit: 32 | _LOGGER.debug( 33 | "Delay %f seconds due to throttling (%d calls per %f seconds allowed for %s).", 34 | self.period / self.rate_limit, 35 | self.rate_limit, 36 | self.period, 37 | self.name, 38 | ) 39 | await asyncio.sleep(self.period / self.rate_limit) 40 | 41 | self._task_logs.append(time.monotonic()) 42 | 43 | async def __aenter__(self): 44 | await self.acquire() 45 | 46 | async def __aexit__(self, exc_type, exc, tb): 47 | pass 48 | -------------------------------------------------------------------------------- /tests/fixtures/site.json: -------------------------------------------------------------------------------- 1 | { 2 | "contactInfo": { 3 | "installerName": null, 4 | "installerPhoneNumber": null, 5 | "ownerName": "John Doe", 6 | "ownerPhoneNumber": "+46070123456", 7 | "company": null 8 | }, 9 | "costPerKWh": 0.0, 10 | "vat": 0.0, 11 | "costPerKwhExcludeVat": 0.0, 12 | "currencyId": "SEK", 13 | "partnerId": 1, 14 | "siteType": null, 15 | "ratedCurrent": 20.0, 16 | "useDynamicMaster": false, 17 | "circuits": [ 18 | { 19 | "id": 12345, 20 | "siteId": 54321, 21 | "circuitPanelId": 1, 22 | "panelName": "1", 23 | "ratedCurrent": 16.0, 24 | "chargers": [ 25 | { 26 | "id": "ES12345", 27 | "name": "Easee Home 12345", 28 | "color": 1, 29 | "createdOn": "2020-03-06T08:52:52.512105", 30 | "updatedOn": "2020-07-03T17:34:35.390791", 31 | "backPlate": { 32 | "id": "backPlateId", 33 | "masterBackPlateId": "masterBackPlateId" 34 | }, 35 | "levelOfAccess": null, 36 | "productCode": 1, 37 | "userRole": 1 38 | } 39 | ], 40 | "masterBackplate": null, 41 | "useDynamicMaster": false 42 | } 43 | ], 44 | "equalizers": [], 45 | "createdOn": "2020-04-06T11:59:53.135015", 46 | "updatedOn": "2020-06-25T08:13:52.310928", 47 | "id": 55555, 48 | "siteKey": "WAFE-1234", 49 | "name": "Site name", 50 | "levelOfAccess": null, 51 | "address": { 52 | "street": "street", 53 | "buildingNumber": "buildingNumber", 54 | "zip": "zip", 55 | "area": "area", 56 | "country": { 57 | "id": "countryid", 58 | "name": "countryname", 59 | "phonePrefix": 46 60 | }, 61 | "latitude": 0, 62 | "longitude": 0, 63 | "altitude": 0 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/fixtures/charger-state.json: -------------------------------------------------------------------------------- 1 | { 2 | "smartCharging": false, 3 | "cableLocked": true, 4 | "chargerOpMode": 2, 5 | "totalPower": 0.0, 6 | "sessionEnergy": 5.395103454589844, 7 | "energyPerHour": 0.0, 8 | "wiFiRSSI": -79, 9 | "cellRSSI": -73, 10 | "localRSSI": null, 11 | "outputPhase": 30, 12 | "dynamicCircuitCurrentP1": 40.0, 13 | "dynamicCircuitCurrentP2": 40.0, 14 | "dynamicCircuitCurrentP3": 40.0, 15 | "latestPulse": "2020-07-18T20:01:16Z", 16 | "chargerFirmware": 230, 17 | "latestFirmware": 230, 18 | "voltage": 240.7689971923828, 19 | "chargerRAT": 1, 20 | "lockCablePermanently": false, 21 | "inCurrentT2": 0.01899999938905239, 22 | "inCurrentT3": 0.009999999776482582, 23 | "inCurrentT4": 0.009999999776482582, 24 | "inCurrentT5": 0.014000000432133675, 25 | "outputCurrent": 16.0, 26 | "isOnline": true, 27 | "inVoltageT1T2": 3.063999891281128, 28 | "inVoltageT1T3": 240.42799377441406, 29 | "inVoltageT1T4": 234.05999755859375, 30 | "inVoltageT1T5": 231.5229949951172, 31 | "inVoltageT2T3": 240.7689971923828, 32 | "inVoltageT2T4": 236.59300231933594, 33 | "inVoltageT2T5": 228.7830047607422, 34 | "inVoltageT3T4": 405.2619934082031, 35 | "inVoltageT3T5": 411.0889892578125, 36 | "inVoltageT4T5": 406.2590026855469, 37 | "ledMode": 23, 38 | "cableRating": 20000.0, 39 | "dynamicChargerCurrent": 32.0, 40 | "circuitTotalAllocatedPhaseConductorCurrentL1": null, 41 | "circuitTotalAllocatedPhaseConductorCurrentL2": null, 42 | "circuitTotalAllocatedPhaseConductorCurrentL3": null, 43 | "circuitTotalPhaseConductorCurrentL1": 0.009999999776482582, 44 | "circuitTotalPhaseConductorCurrentL2": 0.009999999776482582, 45 | "circuitTotalPhaseConductorCurrentL3": 0.013000000268220901, 46 | "reasonForNoCurrent": 0, 47 | "wiFiAPEnabled": false 48 | } 49 | -------------------------------------------------------------------------------- /.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 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # IDEA 107 | .idea/ 108 | -------------------------------------------------------------------------------- /pyeasee/utils.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping 2 | from datetime import datetime, timezone 3 | import re 4 | 5 | from .const import ChargerStreamData, EqualizerStreamData 6 | 7 | regex = r"^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(\.[0-9]+)?(Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])?$" 8 | match_iso8601 = re.compile(regex).match 9 | 10 | 11 | def lookup_charger_stream_id(id): 12 | try: 13 | name = ChargerStreamData(id).name 14 | except ValueError: 15 | return None 16 | return name 17 | 18 | 19 | def lookup_equalizer_stream_id(id): 20 | try: 21 | name = EqualizerStreamData(id).name 22 | except ValueError: 23 | return None 24 | return name 25 | 26 | 27 | def convert_stream_data(data_type, value): 28 | if data_type == 2: 29 | if value.lower() in ["1", "true", "on", "yes"]: 30 | return True 31 | else: 32 | return False 33 | elif data_type == 3: 34 | return float(value) 35 | elif data_type == 4: 36 | return int(value) 37 | 38 | return value 39 | 40 | 41 | def validate_iso8601(str_val): 42 | try: 43 | if match_iso8601(str_val) is not None: 44 | return True 45 | except Exception: 46 | pass 47 | return False 48 | 49 | 50 | class BaseDict(Mapping): 51 | def __init__(self, entries): 52 | self._storage = entries 53 | 54 | def __getitem__(self, key): 55 | if type(self._storage[key]) == str and validate_iso8601(self._storage[key]): 56 | try: 57 | return datetime.fromisoformat(self._storage[key]).replace(tzinfo=timezone.utc) 58 | except ValueError: 59 | try: 60 | return datetime.strptime(self._storage[key], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) 61 | except ValueError: 62 | (dt, msecs) = self._storage[key].split(".") 63 | return datetime.strptime(dt, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc) 64 | return self._storage[key] 65 | 66 | def __setitem__(self, key, value): 67 | self._storage[key] = value 68 | 69 | def __iter__(self): 70 | return iter(self._storage) # ``ghost`` is invisible 71 | 72 | def __len__(self): 73 | return len(self._storage) 74 | 75 | def get_data(self): 76 | return self._storage 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Maintenance](https://img.shields.io/maintenance/yes/2025.svg) 2 | 3 | # Easee EV Charger library 4 | 5 | This library is an async thin wrapper around [Easee's Rest API](https://developer.easee.com/reference/post_api-accounts-login) 6 | 7 | ## Installation 8 | 9 | You can install the libray from [PyPI](https://pypi.org/project/pyeasee/): 10 | 11 | pip install pyeasee 12 | 13 | The library is tested on Python 3.13 14 | 15 | ## Command line tool 16 | 17 | Run `python -m pyeasee -h` for help. 18 | 19 | ## Usage of the library 20 | 21 | ### Docs 22 | 23 | Read the API documentation [here](https://nordicopen.github.io/pyeasee/pyeasee/) 24 | 25 | ### Small example 26 | 27 | Save the example to a file and run it like this: python3 example.py 28 | Username is the phone number that was used to register the Easee account with country code. 29 | E.g. +46xxxxxxxxx. 30 | 31 | ```python 32 | import asyncio 33 | import sys 34 | from pyeasee import Easee 35 | 36 | async def async_main(): 37 | 38 | if len(sys.argv) < 3: 39 | print(f"Usage: {sys.argv[0]} ") 40 | return 41 | 42 | print(f"Logging in using: {sys.argv[1]} {sys.argv[2]}") 43 | easee = Easee(sys.argv[1], sys.argv[2]) 44 | 45 | sites = await easee.get_sites() 46 | for site in sites: 47 | print(f"Site {site.name} ({site.id})") 48 | equalizers = site.get_equalizers() 49 | for equalizer in equalizers: 50 | print(f" Equalizer: {equalizer.name} ({equalizer.id})") 51 | circuits = site.get_circuits() 52 | for circuit in circuits: 53 | print(f" Circuit {circuit.id}") 54 | chargers = circuit.get_chargers() 55 | for charger in chargers: 56 | state = await charger.get_state() 57 | print(f" Charger: {charger.name} ({charger.id}) status: {state['chargerOpMode']}") 58 | 59 | await easee.close() 60 | 61 | asyncio.run(async_main()) 62 | ``` 63 | 64 | See also [\_\_main\_\_.py](https://github.com/nordicopen/pyeasee/blob/master/pyeasee/__main__.py) for a more complete usage example. 65 | 66 | ## Development 67 | 68 | This project uses `black` for code formatting and `flake8` for linting. To autoformat and run lint run 69 | 70 | ``` 71 | make lint 72 | ``` 73 | 74 | ## Attribution, support and cooperation 75 | 76 | This project was started by the late Niklas Fondberg, @fondberg. The repository has been inherited by his collaborators. 77 | -------------------------------------------------------------------------------- /examples/site_consumption.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | from pyeasee import Easee 4 | from datetime import datetime, timezone, timedelta 5 | import polars as pl 6 | 7 | async def async_main(): 8 | if len(sys.argv) < 3: 9 | print(f"Usage: {sys.argv[0]} ") 10 | return 11 | 12 | print(f"Logging in using: {sys.argv[1]} {sys.argv[2]}") 13 | easee = Easee(sys.argv[1], sys.argv[2]) 14 | 15 | sites = await easee.get_sites() 16 | for site in sites: 17 | print(f"Site {site.name} ({site.id})") 18 | circuits = site.get_circuits() 19 | all_dfs = [] 20 | for circuit in circuits: 21 | chargers = circuit.get_chargers() 22 | for charger in chargers: 23 | # Some chargers return an error 24 | try: 25 | state = await charger.get_state() 26 | except Exception as e: 27 | print(f" Error getting state for charger {charger.name} ({charger.id}): {e}") 28 | continue 29 | # Fetch one month at a time, as the API limits the requested time period 30 | charger_consumption = [] 31 | year = 2025 32 | for month in range(1 , 13): 33 | from_date = datetime(year, month, 1, 0, 0, 0, tzinfo=timezone.utc) 34 | if month == 12: 35 | to_date = datetime(year + 1 , 1, 1, 0, 0, 0, tzinfo=timezone.utc) 36 | else: 37 | to_date = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=timezone.utc) 38 | if to_date > datetime.now(timezone.utc): 39 | to_date = datetime.now(timezone.utc) 40 | if from_date >= to_date: 41 | continue 42 | print(f" Getting Charger: {charger.name} ({charger.id}) between {from_date} and {to_date}") 43 | consumption = await charger.get_hourly_consumption_between_dates(from_date, to_date) 44 | charger_consumption.extend(consumption) 45 | print(f" Total hours collected: {len(charger_consumption)}") 46 | if charger_consumption: 47 | df = pl.DataFrame(charger_consumption) 48 | df = df.with_columns([ 49 | pl.col("from").str.to_datetime().dt.replace_time_zone(None).alias("datetime"), 50 | pl.lit(charger.id).alias("chargerId") 51 | ]) 52 | df = df.drop(["from", "to"]) 53 | all_dfs.append(df) 54 | 55 | if all_dfs: 56 | site_consumption = pl.concat(all_dfs) 57 | site_consumption.write_csv(f"hourly_consumption_{site.id}.csv") 58 | sum_df = site_consumption.group_by("datetime").agg( 59 | pl.col("totalEnergy").sum(), 60 | ) 61 | sum_df.write_excel(f"hourly_consumption_aggregate_{site.id}.xlsx") 62 | print("Data written to hourly_consumption_{site.id}.csv and hourly_consumption_aggregate_{site.id}.xlsx") 63 | await easee.close() 64 | 65 | asyncio.run(async_main()) -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import os 4 | 5 | import aiohttp 6 | import pytest 7 | import pytest_asyncio 8 | from aioresponses import aioresponses 9 | from pyeasee import Charger, Easee 10 | 11 | BASE_URL = "https://api.easee.com" 12 | 13 | 14 | def load_json_fixture(filename): 15 | with open(os.path.join(os.path.dirname(__file__), "fixtures", filename)) as f: 16 | return json.load(f) 17 | 18 | 19 | @pytest_asyncio.fixture 20 | async def aioresponse(): 21 | with aioresponses() as m: 22 | yield m 23 | 24 | 25 | @pytest_asyncio.fixture 26 | async def aiosession(): 27 | return aiohttp.ClientSession() 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_get_chargers(aiosession, aioresponse): 32 | 33 | token_data = load_json_fixture("token.json") 34 | aioresponse.post(f"{BASE_URL}/api/accounts/login", payload=token_data) 35 | 36 | chargers_data = load_json_fixture("chargers.json") 37 | aioresponse.get(f"{BASE_URL}/api/chargers", payload=chargers_data) 38 | 39 | easee = Easee("+46070123456", "password", aiosession) 40 | chargers = await easee.get_chargers() 41 | assert chargers[0].id == "EH12345" 42 | 43 | chargers_state_data = load_json_fixture("charger-state.json") 44 | aioresponse.get(f"{BASE_URL}/api/chargers/EH12345/state", payload=chargers_state_data) 45 | 46 | state = await chargers[0].get_state(raw=True) 47 | 48 | assert state["chargerOpMode"] == 2 49 | 50 | await easee.close() 51 | await aiosession.close() 52 | 53 | 54 | @pytest.mark.asyncio 55 | async def test_get_sites(aiosession, aioresponse): 56 | 57 | token_data = load_json_fixture("token.json") 58 | aioresponse.post(f"{BASE_URL}/api/accounts/login", payload=token_data) 59 | 60 | sites_data = load_json_fixture("sites.json") 61 | aioresponse.get(f"{BASE_URL}/api/sites", payload=sites_data) 62 | 63 | site_data = load_json_fixture("site.json") 64 | aioresponse.get(f"{BASE_URL}/api/sites/55555?detailed=true", payload=site_data) 65 | 66 | easee = Easee("+46070123456", "password", aiosession) 67 | sites = await easee.get_sites() 68 | 69 | assert sites[0].id == 55555 70 | 71 | circuits = sites[0].get_circuits() 72 | assert circuits[0].id == 12345 73 | 74 | chargers_data = load_json_fixture("chargers.json") 75 | aioresponse.get(f"{BASE_URL}/api/chargers", payload=chargers_data) 76 | 77 | chargers = circuits[0].get_chargers() 78 | 79 | assert chargers[0].id == "ES12345" 80 | await easee.close() 81 | await aiosession.close() 82 | 83 | 84 | @pytest.mark.asyncio 85 | async def test_get_site_state(aiosession, aioresponse): 86 | 87 | token_data = load_json_fixture("token.json") 88 | aioresponse.post(f"{BASE_URL}/api/accounts/login", payload=token_data) 89 | 90 | site_state_data = load_json_fixture("site-state.json") 91 | aioresponse.get(f"{BASE_URL}/api/sites/54321/state", payload=site_state_data) 92 | 93 | easee = Easee("+46070123456", "password", aiosession) 94 | site_state = await easee.get_site_state("54321") 95 | 96 | charger_config = site_state.get_charger_config("EH123497") 97 | assert charger_config["localNodeType"] == "Master" 98 | 99 | charger_state = site_state.get_charger_state("EH123497", raw=True) 100 | 101 | assert charger_state["chargerOpMode"] == 1 102 | 103 | 104 | charger_state = site_state.get_charger_state("NOTEXIST") 105 | assert charger_state is None 106 | 107 | await easee.close() 108 | await aiosession.close() 109 | -------------------------------------------------------------------------------- /tests/test_charger.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pyeasee import Charger 3 | 4 | 5 | class MockResponse: 6 | def __init__(self, data): 7 | self.data = data 8 | 9 | async def json(self): 10 | return self.data 11 | 12 | 13 | class MockEasee: 14 | def __init__(self, get_data={}, post_data={}): 15 | self.get_data = get_data 16 | self.post_data = post_data 17 | 18 | async def post(self, url, **kwargs): 19 | return MockResponse(self.post_data) 20 | 21 | async def get(self, url, **kwargs): 22 | return MockResponse(self.get_data) 23 | 24 | 25 | default_state = { 26 | "smartCharging": False, 27 | "cableLocked": False, 28 | "chargerOpMode": 3, 29 | "totalPower": 0.0, 30 | "sessionEnergy": 5.587664604187012, 31 | "energyPerHour": 0.0, 32 | "wiFiRSSI": -66, 33 | "cellRSSI": -73, 34 | "localRSSI": None, 35 | "outputPhase": 0, 36 | "dynamicCircuitCurrentP1": 40.0, 37 | "dynamicCircuitCurrentP2": 40.0, 38 | "dynamicCircuitCurrentP3": 40.0, 39 | "latestPulse": "2020-07-09T11:37:37Z", 40 | "chargerFirmware": 230, 41 | "latestFirmware": 230, 42 | "voltage": 241.61900329589844, 43 | "chargerRAT": 1, 44 | "lockCablePermanently": False, 45 | "inCurrentT2": 0.0, 46 | "inCurrentT3": 0.0, 47 | "inCurrentT4": 0.0, 48 | "inCurrentT5": 0.0, 49 | "outputCurrent": 0.0, 50 | "isOnline": True, 51 | "inVoltageT1T2": 3.0850000381469727, 52 | "inVoltageT1T3": 241.29299926757812, 53 | "inVoltageT1T4": 235.20799255371094, 54 | "inVoltageT1T5": 235.1269989013672, 55 | "inVoltageT2T3": 241.61900329589844, 56 | "inVoltageT2T4": 237.75399780273438, 57 | "inVoltageT2T5": 232.3679962158203, 58 | "inVoltageT3T4": 408.0639953613281, 59 | "inVoltageT3T5": 414.3399963378906, 60 | "inVoltageT4T5": 410.01800537109375, 61 | "ledMode": 18, 62 | "cableRating": 20000.0, 63 | "dynamicChargerCurrent": 32.0, 64 | "circuitTotalAllocatedPhaseConductorCurrentL1": None, 65 | "circuitTotalAllocatedPhaseConductorCurrentL2": None, 66 | "circuitTotalAllocatedPhaseConductorCurrentL3": None, 67 | "circuitTotalPhaseConductorCurrentL1": 0.0, 68 | "circuitTotalPhaseConductorCurrentL2": 0.0, 69 | "circuitTotalPhaseConductorCurrentL3": 0.0, 70 | "reasonForNoCurrent": 50, 71 | "wiFiAPEnabled": False, 72 | } 73 | 74 | default_config = { 75 | "isEnabled": True, 76 | "lockCablePermanently": False, 77 | "authorizationRequired": False, 78 | "remoteStartRequired": True, 79 | "smartButtonEnabled": False, 80 | "wiFiSSID": "wifi ssid", 81 | "detectedPowerGridType": 1, 82 | "offlineChargingMode": 0, 83 | "circuitMaxCurrentP1": 16.0, 84 | "circuitMaxCurrentP2": 16.0, 85 | "circuitMaxCurrentP3": 16.0, 86 | "enableIdleCurrent": False, 87 | "limitToSinglePhaseCharging": None, 88 | "phaseMode": 3, 89 | "localNodeType": 1, 90 | "localAuthorizationRequired": False, 91 | "localRadioChannel": 7, 92 | "localShortAddress": 0, 93 | "localParentAddrOrNumOfNodes": 0, 94 | "localPreAuthorizeEnabled": None, 95 | "localAuthorizeOfflineEnabled": None, 96 | "allowOfflineTxForUnknownId": None, 97 | "maxChargerCurrent": 32.0, 98 | "ledStripBrightness": None, 99 | } 100 | 101 | 102 | @pytest.mark.asyncio 103 | async def test_get_correct_status(): 104 | mock_easee = MockEasee(get_data=default_state) 105 | charger = Charger({"id": "EH123456", "name": "Easee Home 12345", "productCode": 1, "userRole": 1, "levelOfAccess": 1}, mock_easee) 106 | state = await charger.get_state(raw=True) 107 | assert state["chargerOpMode"] == 3 108 | 109 | 110 | @pytest.mark.asyncio 111 | async def test_get_correct_phase_mode(): 112 | mock_easee = MockEasee(get_data=default_config) 113 | charger = Charger({"id": "EH123456", "name": "Easee Home 12345", "productCode": 1, "userRole": 1, "levelOfAccess": 1}, mock_easee) 114 | state = await charger.get_config() 115 | assert state["phaseMode"] == "Locked to three phase" 116 | -------------------------------------------------------------------------------- /tests/fixtures/site-state.json: -------------------------------------------------------------------------------- 1 | { 2 | "circuitStates": [ 3 | { 4 | "circuit": { 5 | "id": 12345, 6 | "siteId": 54321, 7 | "circuitPanelId": 1, 8 | "panelName": "1", 9 | "ratedCurrent": 16, 10 | "chargers": [ 11 | { 12 | "id": "EH123497", 13 | "name": "Easee Home 12349", 14 | "color": 1, 15 | "createdOn": "2020-03-06T08:52:52.512105", 16 | "updatedOn": "2020-07-03T17:34:35.390791", 17 | "backPlate": { 18 | "id": "ABCD1234567890", 19 | "masterBackPlateId": "ABCD1234567890" 20 | }, 21 | "levelOfAccess": null, 22 | "productCode": 1, 23 | "userRole": 1 24 | } 25 | ], 26 | "masterBackplate": null, 27 | "useDynamicMaster": false, 28 | "parentCircuitId": null 29 | }, 30 | "chargerStates": [ 31 | { 32 | "chargerID": "EH123497", 33 | "chargerConfig": { 34 | "isEnabled": true, 35 | "lockCablePermanently": false, 36 | "authorizationRequired": false, 37 | "remoteStartRequired": true, 38 | "smartButtonEnabled": false, 39 | "wiFiSSID": "wifi ssid", 40 | "detectedPowerGridType": 1, 41 | "offlineChargingMode": 0, 42 | "circuitMaxCurrentP1": 16, 43 | "circuitMaxCurrentP2": 16, 44 | "circuitMaxCurrentP3": 16, 45 | "enableIdleCurrent": false, 46 | "limitToSinglePhaseCharging": null, 47 | "phaseMode": 2, 48 | "localNodeType": 1, 49 | "localAuthorizationRequired": false, 50 | "localRadioChannel": 6, 51 | "localShortAddress": 0, 52 | "localParentAddrOrNumOfNodes": 0, 53 | "localPreAuthorizeEnabled": true, 54 | "localAuthorizeOfflineEnabled": true, 55 | "allowOfflineTxForUnknownId": true, 56 | "maxChargerCurrent": 32, 57 | "ledStripBrightness": null 58 | }, 59 | "chargerState": { 60 | "smartCharging": false, 61 | "cableLocked": false, 62 | "chargerOpMode": 1, 63 | "totalPower": 0, 64 | "sessionEnergy": 4.34719800949097, 65 | "energyPerHour": 0, 66 | "wiFiRSSI": -51, 67 | "cellRSSI": -68, 68 | "localRSSI": null, 69 | "outputPhase": 0, 70 | "dynamicCircuitCurrentP1": 40, 71 | "dynamicCircuitCurrentP2": 40, 72 | "dynamicCircuitCurrentP3": 40, 73 | "latestPulse": "2020-10-01T08:30:55Z", 74 | "chargerFirmware": 230, 75 | "latestFirmware": 240, 76 | "voltage": 239.75, 77 | "chargerRAT": 1, 78 | "lockCablePermanently": false, 79 | "inCurrentT2": 0, 80 | "inCurrentT3": 0, 81 | "inCurrentT4": 0, 82 | "inCurrentT5": 0, 83 | "outputCurrent": 0, 84 | "isOnline": true, 85 | "inVoltageT1T2": 3.065999984741211, 86 | "inVoltageT1T3": 239.41700744628906, 87 | "inVoltageT1T4": 234.70700073242188, 88 | "inVoltageT1T5": 231.13099670410156, 89 | "inVoltageT2T3": 239.75, 90 | "inVoltageT2T4": 237.23899841308594, 91 | "inVoltageT2T5": 228.4029998779297, 92 | "inVoltageT3T4": 405.15301513671875, 93 | "inVoltageT3T5": 410.27398681640625, 94 | "inVoltageT4T5": 405.8890075683594, 95 | "ledMode": 18, 96 | "cableRating": 20000, 97 | "dynamicChargerCurrent": 32, 98 | "circuitTotalAllocatedPhaseConductorCurrentL1": null, 99 | "circuitTotalAllocatedPhaseConductorCurrentL2": null, 100 | "circuitTotalAllocatedPhaseConductorCurrentL3": null, 101 | "circuitTotalPhaseConductorCurrentL1": null, 102 | "circuitTotalPhaseConductorCurrentL2": null, 103 | "circuitTotalPhaseConductorCurrentL3": null, 104 | "reasonForNoCurrent": null, 105 | "wiFiAPEnabled": false 106 | } 107 | } 108 | ] 109 | } 110 | ], 111 | "site": { 112 | "contactInfo": { 113 | "installerName": null, 114 | "installerPhoneNumber": null, 115 | "ownerName": "Niklas Fondberg", 116 | "ownerPhoneNumber": "+460701234567", 117 | "company": null 118 | }, 119 | "costPerKWh": 0, 120 | "vat": 0, 121 | "costPerKwhExcludeVat": 0, 122 | "currencyId": "SEK", 123 | "partnerId": 1, 124 | "siteType": 1, 125 | "ratedCurrent": 20, 126 | "useDynamicMaster": false, 127 | "circuits": [ 128 | { 129 | "id": 12345, 130 | "siteId": 54321, 131 | "circuitPanelId": 1, 132 | "panelName": "1", 133 | "ratedCurrent": 16, 134 | "chargers": [ 135 | { 136 | "id": "EH123497", 137 | "name": "Easee Home 12349", 138 | "color": 1, 139 | "createdOn": "2020-03-06T08:52:52.512105", 140 | "updatedOn": "2020-07-03T17:34:35.390791", 141 | "backPlate": { 142 | "id": "ABCD1234567890", 143 | "masterBackPlateId": "ABCD1234567890" 144 | }, 145 | "levelOfAccess": null, 146 | "productCode": 1, 147 | "userRole": 1 148 | } 149 | ], 150 | "masterBackplate": null, 151 | "useDynamicMaster": false, 152 | "parentCircuitId": null 153 | } 154 | ], 155 | "equalizers": [], 156 | "createdOn": "2020-04-06T11:59:53.135015", 157 | "updatedOn": "2020-08-14T02:12:29.215533", 158 | "id": 54321, 159 | "siteKey": "WAFE-1234", 160 | "name": "Site name", 161 | "levelOfAccess": null, 162 | "address": { 163 | "street": "Streetname", 164 | "buildingNumber": "1a", 165 | "zip": "12345", 166 | "area": "Stockholm", 167 | "country": { 168 | "id": "SE", 169 | "name": "Sweden", 170 | "phonePrefix": 0 171 | }, 172 | "latitude": null, 173 | "longitude": null, 174 | "altitude": null 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /html/pyeasee/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | pyeasee API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 |
22 |

Package pyeasee

23 |
24 |
25 |

Easee charger API library

26 |
27 |
28 |

Sub-modules

29 |
30 |
pyeasee.charger
31 |
32 |
33 |
34 |
pyeasee.const
35 |
36 |
37 |
38 |
pyeasee.easee
39 |
40 |

Main client for the Eesee account.

41 |
42 |
pyeasee.exceptions
43 |
44 |
45 |
46 |
pyeasee.site
47 |
48 |
49 |
50 |
pyeasee.throttler
51 |
52 |

Throttler for API calls

53 |
54 |
pyeasee.utils
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |

Functions

64 |
65 |
66 | def match_iso8601(string, pos=0, endpos=9223372036854775807) 67 |
68 |
69 |

Matches zero or more characters at the beginning of the string.

70 |
71 |
72 |
73 |
74 |
75 |
76 | 100 |
101 | 104 | 105 | -------------------------------------------------------------------------------- /html/pyeasee/utils.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | pyeasee.utils API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 |
22 |

Module pyeasee.utils

23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |

Functions

32 |
33 |
34 | def convert_stream_data(data_type, value) 35 |
36 |
37 |
38 |
39 |
40 | def lookup_charger_stream_id(id) 41 |
42 |
43 |
44 |
45 |
46 | def lookup_equalizer_stream_id(id) 47 |
48 |
49 |
50 |
51 |
52 | def match_iso8601(string, pos=0, endpos=9223372036854775807) 53 |
54 |
55 |

Matches zero or more characters at the beginning of the string.

56 |
57 |
58 | def validate_iso8601(str_val) 59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |

Classes

67 |
68 |
69 | class BaseDict 70 | (entries) 71 |
72 |
73 |

A Mapping is a generic container for associating key/value 74 | pairs.

75 |

This class provides concrete generic implementations of all 76 | methods except for getitem, iter, and len.

77 |

Ancestors

78 |
    79 |
  • collections.abc.Mapping
  • 80 |
  • collections.abc.Collection
  • 81 |
  • collections.abc.Sized
  • 82 |
  • collections.abc.Iterable
  • 83 |
  • collections.abc.Container
  • 84 |
85 |

Subclasses

86 | 100 |

Methods

101 |
102 |
103 | def get_data(self) 104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 | 145 |
146 | 149 | 150 | -------------------------------------------------------------------------------- /html/pyeasee/exceptions.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | pyeasee.exceptions API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 |
22 |

Module pyeasee.exceptions

23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |

Classes

34 |
35 |
36 | class AuthorizationFailedException 37 | (*args, **kwargs) 38 |
39 |
40 |

Common base class for all non-exit exceptions.

41 |

Ancestors

42 |
    43 |
  • builtins.Exception
  • 44 |
  • builtins.BaseException
  • 45 |
46 |
47 |
48 | class BadRequestException 49 | (*args) 50 |
51 |
52 |

Bad arguments or operation not allowed in this context.

53 |

Ancestors

54 | 59 |
60 |
61 | class ExceptionWithDict 62 | (*args) 63 |
64 |
65 |

Common base class for all non-exit exceptions.

66 |

Ancestors

67 |
    68 |
  • builtins.Exception
  • 69 |
  • builtins.BaseException
  • 70 |
71 |

Subclasses

72 | 76 |
77 |
78 | class ForbiddenServiceException 79 | (*args) 80 |
81 |
82 |

Authenticated but access to resource not allowed.

83 |

Ancestors

84 | 89 |
90 |
91 | class NotFoundException 92 | (*args, **kwargs) 93 |
94 |
95 |

Common base class for all non-exit exceptions.

96 |

Ancestors

97 |
    98 |
  • builtins.Exception
  • 99 |
  • builtins.BaseException
  • 100 |
101 |
102 |
103 | class ServerFailureException 104 | (*args, **kwargs) 105 |
106 |
107 |

Common base class for all non-exit exceptions.

108 |

Ancestors

109 |
    110 |
  • builtins.Exception
  • 111 |
  • builtins.BaseException
  • 112 |
113 |
114 |
115 | class TooManyRequestsException 116 | (*args, **kwargs) 117 |
118 |
119 |

Common base class for all non-exit exceptions.

120 |

Ancestors

121 |
    122 |
  • builtins.Exception
  • 123 |
  • builtins.BaseException
  • 124 |
125 |
126 |
127 |
128 |
129 | 167 |
168 | 171 | 172 | -------------------------------------------------------------------------------- /pyeasee/site.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import logging 3 | from typing import Any, Dict, List 4 | 5 | from .charger import Charger, ChargerConfig, ChargerState 6 | from .exceptions import ForbiddenServiceException, ServerFailureException 7 | from .throttler import Throttler 8 | from .utils import BaseDict 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | class EqualizerState(BaseDict): 14 | def __init__(self, state: Dict[str, Any]): 15 | data = {**state} 16 | super().__init__(data) 17 | 18 | 19 | class EqualizerConfig(BaseDict): 20 | def __init__(self, config: Dict[str, Any]): 21 | data = {**config} 22 | super().__init__(data) 23 | 24 | 25 | class Equalizer(BaseDict): 26 | def __init__(self, data: Dict[str, Any], site: Any, easee: Any): 27 | super().__init__(data) 28 | self.id: str = data["id"] 29 | self.name: str = data["name"] 30 | self.site = site 31 | self.easee = easee 32 | self._max_allocated_current_throttler = Throttler(rate_limit=1, period=60, name="max allocated current") 33 | 34 | async def get_observations(self, *args): 35 | """Gets observation IDs""" 36 | observation_ids = ",".join(str(s) for s in args) 37 | try: 38 | return await (await self.easee.get(f"/state/{self.id}/observations?ids={observation_ids}")).json() 39 | except (ServerFailureException): 40 | return None 41 | 42 | async def get_state(self): 43 | """Get Equalizer state""" 44 | try: 45 | state = await (await self.easee.get(f"/api/equalizers/{self.id}/state")).json() 46 | return EqualizerState(state) 47 | except (ServerFailureException): 48 | return None 49 | 50 | async def get_config(self): 51 | """Get Equalizer config""" 52 | try: 53 | config = await (await self.easee.get(f"/api/equalizers/{self.id}/config")).json() 54 | return EqualizerConfig(config) 55 | except (ServerFailureException): 56 | return None 57 | 58 | async def empty_state(self, raw=False): 59 | """Create an empty state data structyre""" 60 | state = {} 61 | return EqualizerState(state) 62 | 63 | async def empty_config(self, raw=False): 64 | """Crate an empty config data structure""" 65 | config = {} 66 | return EqualizerConfig(config) 67 | 68 | async def get_load_balancing(self): 69 | """Get the load balancing settings""" 70 | try: 71 | return await ( 72 | await self.easee.get(f"/cloud-loadbalancing/equalizer/{self.id}/config/surplus-energy") 73 | ).json() 74 | except (ServerFailureException): 75 | return None 76 | 77 | async def set_load_balancing(self, activate: bool, current_limit: int = 0): 78 | """Set the load balancing settings""" 79 | if activate is True: 80 | json = { 81 | "mode": "chargingWithImport", 82 | "chargingWithImport": {"eligible": activate, "maximumImportAddedCurrent": current_limit}, 83 | } 84 | else: 85 | json = { 86 | "mode": "none", 87 | } 88 | 89 | try: 90 | return await self.easee.post(f"/cloud-loadbalancing/equalizer/{self.id}/config/surplus-energy", json=json) 91 | except (ServerFailureException): 92 | return None 93 | 94 | async def set_max_allocated_current(self, current_limit: int): 95 | """Set the load balancing settings""" 96 | json = { 97 | "maxCurrent": current_limit, 98 | } 99 | 100 | try: 101 | async with self._max_allocated_current_throttler: 102 | return await self.easee.post( 103 | f"/api/equalizers/{self.id}/commands/configure_max_allocated_current", json=json 104 | ) 105 | except (ServerFailureException): 106 | return None 107 | 108 | async def get_latest_firmware(self): 109 | """Get the latest released firmeware version""" 110 | try: 111 | return await (await self.easee.get(f"/firmware/{self.id}/latest")).json() 112 | except (ServerFailureException): 113 | return None 114 | 115 | 116 | class Circuit(BaseDict): 117 | """Represents a Circuit""" 118 | 119 | def __init__(self, data: Dict[str, Any], site: Any, easee: Any): 120 | super().__init__(data) 121 | self.id: int = data["id"] 122 | self.site = site 123 | self.easee = easee 124 | 125 | async def set_dynamic_current( 126 | self, currentP1: int, currentP2: int = None, currentP3: int = None, timeToLive: int = 0 127 | ): 128 | """Set dynamic current on circuit level. timeToLive specifies, in minutes, for how long the new dynamic current is valid. timeToLive = 0 means that the new dynamic current is valid until changed the next time. The dynamic current is always reset to default when the charger is restarted""" 129 | json = { 130 | "phase1": currentP1, 131 | "phase2": currentP2 if currentP2 is not None else currentP1, 132 | "phase3": currentP3 if currentP3 is not None else currentP1, 133 | "timeToLive": timeToLive, 134 | } 135 | try: 136 | return await self.easee.post(f"/api/sites/{self.site.id}/circuits/{self.id}/dynamicCurrent", json=json) 137 | except (ServerFailureException): 138 | return None 139 | 140 | async def set_max_current(self, currentP1: int, currentP2: int = None, currentP3: int = None): 141 | """Set circuit max current""" 142 | json = { 143 | "maxCircuitCurrentP1": currentP1, 144 | "maxCircuitCurrentP2": currentP2 if currentP2 is not None else currentP1, 145 | "maxCircuitCurrentP3": currentP3 if currentP3 is not None else currentP1, 146 | } 147 | try: 148 | return await self.easee.post(f"/api/sites/{self.site.id}/circuits/{self.id}/settings", json=json) 149 | except (ServerFailureException): 150 | return None 151 | 152 | async def set_max_offline_current(self, currentP1: int, currentP2: int = None, currentP3: int = None): 153 | """Set circuit max offline current, fallback value for limit if charger is offline""" 154 | json = { 155 | "offlineMaxCircuitCurrentP1": currentP1, 156 | "offlineMaxCircuitCurrentP2": currentP2 if currentP2 is not None else currentP1, 157 | "offlineMaxCircuitCurrentP3": currentP3 if currentP3 is not None else currentP1, 158 | } 159 | try: 160 | return await self.easee.post(f"/api/sites/{self.site.id}/circuits/{self.id}/settings", json=json) 161 | except (ServerFailureException): 162 | return None 163 | 164 | async def set_rated_current(self, ratedCurrentFuseValue: int): 165 | """Set circuit rated current - requires elevated access (installers only)""" 166 | json = {"ratedCurrentFuseValue": ratedCurrentFuseValue} 167 | try: 168 | return await self.easee.post(f"/api/sites/{self.site.id}/circuits/{self.id}/rated_current", json=json) 169 | except (ServerFailureException): 170 | return None 171 | 172 | def get_chargers(self) -> List[Charger]: 173 | return [Charger(c, self.easee, self.site, self) for c in self["chargers"]] 174 | 175 | 176 | class SiteState(BaseDict): 177 | """Represents the site state as received through /sites//state""" 178 | 179 | def __init__(self, data: Dict[str, Any]): 180 | super().__init__(data) 181 | 182 | def get_charger_config(self, charger_id: str, raw=False) -> ChargerConfig: 183 | """get config for charger from the instance data""" 184 | for circuit in self["circuitStates"]: 185 | for charger_data in circuit["chargerStates"]: 186 | if charger_data["chargerID"] == charger_id: 187 | return ChargerConfig(charger_data["chargerConfig"], raw) 188 | 189 | return None 190 | 191 | def get_charger_state(self, charger_id: str, raw=False) -> ChargerState: 192 | """get state for charger from the instance data""" 193 | for circuit in self["circuitStates"]: 194 | for charger_data in circuit["chargerStates"]: 195 | if charger_data["chargerID"] == charger_id: 196 | return ChargerState(charger_data["chargerState"], raw) 197 | 198 | return None 199 | 200 | 201 | class Site(BaseDict): 202 | """Represents a Site""" 203 | 204 | def __init__(self, data: Dict[str, Any], easee: Any): 205 | super().__init__(data) 206 | self.id: int = data["id"] 207 | self.name: str = data["name"] 208 | self.easee = easee 209 | self._breakdown_throttler = Throttler(rate_limit=10, period=3600, name="price breakdown") 210 | 211 | def get_circuits(self) -> List[Circuit]: 212 | """Get circuits for the site""" 213 | return [Circuit(c, self, self.easee) for c in self["circuits"]] 214 | 215 | def get_equalizers(self) -> List[Equalizer]: 216 | """Get equalizers for the site""" 217 | return [Equalizer(e, self, self.easee) for e in self["equalizers"]] 218 | 219 | async def set_name(self, name: str): 220 | """Set name for the site""" 221 | json = {**self.get_data(), "name": name} 222 | return await self.easee.put(f"/api/sites/{self.id}", json=json) 223 | 224 | async def set_currency(self, currency: str): 225 | """Set currency for the site""" 226 | json = {**self.get_data(), "currencyId": currency} 227 | val = await self.easee.put(f"/api/sites/{self.id}", json=json) 228 | self["currencyId"] = currency 229 | return val 230 | 231 | async def set_price( 232 | self, 233 | costPerKWh: float, 234 | vat: float = None, 235 | currency: str = None, 236 | costPerKwhExcludeVat: float = None, 237 | ): 238 | """Set price per kWh for the site""" 239 | 240 | json = {"costPerKWh": costPerKWh} 241 | 242 | if vat is None: 243 | vat = self.get("vat") 244 | 245 | if currency is None: 246 | currency = self.get("currencyId") 247 | 248 | if costPerKwhExcludeVat is None: 249 | costPerKwhExcludeVat = costPerKWh / (100.0 + vat) * 100.0 250 | 251 | json = { 252 | "currencyId": currency, 253 | "costPerKWh": costPerKWh, 254 | "vat": vat, 255 | "costPerKwhExcludeVat": costPerKwhExcludeVat, 256 | } 257 | 258 | try: 259 | val = await self.easee.post(f"/api/sites/{self.id}/price", json=json) 260 | self["vat"] = vat 261 | self["currencyId"] = currency 262 | self["costPerKWh"] = costPerKWh 263 | self["costPerKwhExcludeVat"] = costPerKwhExcludeVat 264 | return val 265 | except (ServerFailureException): 266 | return None 267 | 268 | async def get_cost_between_dates(self, from_date: datetime, to_date: datetime): 269 | """Get the charging cost between from_datetime and to_datetime""" 270 | 271 | try: 272 | async with self._breakdown_throttler: 273 | costs = await ( 274 | await self.easee.get( 275 | f"/api/sites/{self.id}/breakdown/{from_date.isoformat()}/{to_date.isoformat()}" 276 | ) 277 | ).json() 278 | return costs 279 | except (ServerFailureException, ForbiddenServiceException): 280 | return None 281 | 282 | async def get_users(self): 283 | """Get a list of users connected to this site""" 284 | 285 | try: 286 | users = await (await self.easee.get(f"/api/sites/{self.id}/users")).json() 287 | return users 288 | except (ServerFailureException, ForbiddenServiceException): 289 | return None 290 | 291 | async def get_user_monthly_consumption(self, user_id): 292 | """Get user consumption""" 293 | 294 | try: 295 | consumption = await (await self.easee.get(f"/api/sites/{self.id}/users/{user_id}/monthly")).json() 296 | return consumption 297 | except (ServerFailureException, ForbiddenServiceException): 298 | return None 299 | 300 | async def get_user_yearly_consumption(self, user_id): 301 | """Get user consumption""" 302 | 303 | try: 304 | consumption = await (await self.easee.get(f"/api/sites/{self.id}/users/{user_id}/yearly")).json() 305 | return consumption 306 | except (ServerFailureException, ForbiddenServiceException): 307 | return None 308 | -------------------------------------------------------------------------------- /html/pyeasee/easee.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | pyeasee.easee API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 |
22 |

Module pyeasee.easee

23 |
24 |
25 |

Main client for the Eesee account.

26 |
27 |
28 |
29 |
30 |
31 |
32 |

Functions

33 |
34 |
35 | async def raise_for_status(response) 36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |

Classes

44 |
45 |
46 | class Easee 47 | (username, password, session: aiohttp.client.ClientSession | None = None, user_agent=None, ssl: ssl.SSLContext | None = None) 48 |
49 |
50 |
51 |

Methods

52 |
53 |
54 | def base_uri(self) 55 |
56 |
57 |
58 |
59 |
60 | async def check_status(self, response) 61 |
62 |
63 |
64 |
65 |
66 | async def close(self) 67 |
68 |
69 |

Close the underlying aiohttp session

70 |
71 |
72 | async def connect(self) 73 |
74 |
75 |

Connect and gets initial token

76 |
77 |
78 | async def delete(self, url, **kwargs) 79 |
80 |
81 |
82 |
83 |
84 | async def get(self, url, **kwargs) 85 |
86 |
87 |
88 |
89 |
90 | async def get_account_products(self) ‑> List[Site] 91 |
92 |
93 |

Get all sites and products that are accessible by the logged in user

94 |
95 |
96 | async def get_active_countries(self) ‑> List[Any] 97 |
98 |
99 |

Get all active countries

100 |
101 |
102 | async def get_chargers(self) ‑> List[Charger] 103 |
104 |
105 |

Retrieve all chargers

106 |
107 |
108 | async def get_currencies(self) ‑> List[Any] 109 |
110 |
111 |

Get all currencies

112 |
113 |
114 | async def get_site(self, id: int) ‑> Site 115 |
116 |
117 |

get site by id

118 |
119 |
120 | async def get_site_state(self, id: str) ‑> SiteState 121 |
122 |
123 |

Get site state

124 |
125 |
126 | async def get_sites(self) ‑> List[Site] 127 |
128 |
129 |

Get all sites

130 |
131 |
132 | async def post(self, url, **kwargs) 133 |
134 |
135 |
136 |
137 |
138 | async def put(self, url, **kwargs) 139 |
140 |
141 |
142 |
143 |
144 | def sr_is_connected(self) 145 |
146 |
147 |
148 |
149 |
150 | async def sr_subscribe(self, product, callback) 151 |
152 |
153 |

Subscribe to signalr events for product, callback will be called as async callback(product_id, data_type, data_id, value)

154 |
155 |
156 | async def sr_unsubscribe(self, product) 157 |
158 |
159 |

Unsubscribe from signalr events for product 160 | BUG: Does not work

161 |
162 |
163 |
164 |
165 |
166 |
167 | 212 |
213 | 216 | 217 | -------------------------------------------------------------------------------- /pyeasee/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | from datetime import datetime 4 | import json 5 | import logging 6 | import sys 7 | import threading 8 | from typing import List 9 | 10 | from . import Charger, Circuit, DatatypesStreamData, Easee, Equalizer, Site 11 | from .utils import lookup_charger_stream_id, lookup_equalizer_stream_id 12 | 13 | CACHED_TOKEN = "easee-token.json" 14 | 15 | _LOGGER = logging.getLogger(__file__) 16 | 17 | 18 | def add_input(queue): 19 | queue.put_nowait(sys.stdin.read(1)) 20 | 21 | 22 | async def print_signalr(id, data_type, data_id, value): 23 | 24 | type_str = DatatypesStreamData(data_type).name 25 | if id[0] == "Q": 26 | data_str = lookup_equalizer_stream_id(data_id) 27 | else: 28 | data_str = lookup_charger_stream_id(data_id) 29 | 30 | print(f"SR: {id} data type {data_type} {type_str} data id {data_id} {data_str} value {value}") 31 | 32 | 33 | def parse_arguments(): 34 | parser = argparse.ArgumentParser(description="Read data from your Easee EV installation") 35 | parser.add_argument("-u", "--username", help="Username", required=True) 36 | parser.add_argument("-p", "--password", help="Password", required=True) 37 | parser.add_argument("-c", "--chargers", help="Get chargers information", action="store_true") 38 | parser.add_argument("-s", "--sites", help="Get sites information", action="store_true") 39 | parser.add_argument("-ci", "--circuits", help="Get circuits information", action="store_true") 40 | parser.add_argument("-e", "--equalizers", help="Get equalizers information", action="store_true") 41 | parser.add_argument( 42 | "-a", 43 | "--all", 44 | help="Get all sites, circuits, equalizers and chargers information", 45 | action="store_true", 46 | ) 47 | parser.add_argument( 48 | "-sum", 49 | "--summary", 50 | help="Get summary of sites, circuits, equalizers and chargers information", 51 | action="store_true", 52 | ) 53 | parser.add_argument( 54 | "-f", "--force", help="Force update of lifetime energy and charger opmode ", action="store_true" 55 | ) 56 | parser.add_argument("-l", "--loop", help="Loop charger data every 5 seconds", action="store_true") 57 | parser.add_argument("-r", "--signalr", help="Listen to signalr stream", action="store_true") 58 | parser.add_argument("-co", "--cost", help="Retrieve cost for last year", action="store_true") 59 | parser.add_argument("--countries", help="Get active countries information", action="store_true") 60 | parser.add_argument("-ou", "--ocppurl", help="OCPP URL") 61 | parser.add_argument("-od", "--ocppdisable", help="OCPP Disable", action="store_true") 62 | parser.add_argument( 63 | "-d", 64 | "--debug", 65 | help="Print debugging statements", 66 | action="store_const", 67 | dest="loglevel", 68 | const=logging.DEBUG, 69 | default=logging.WARNING, 70 | ) 71 | parser.add_argument( 72 | "-v", 73 | "--verbose", 74 | help="Be verbose", 75 | action="store_const", 76 | dest="loglevel", 77 | const=logging.INFO, 78 | ) 79 | args = parser.parse_args() 80 | logging.basicConfig( 81 | format="%(asctime)-15s %(name)-5s %(levelname)-8s %(message)s", 82 | level=args.loglevel, 83 | ) 84 | return args 85 | 86 | 87 | # TODO: Add option to send in a cached token 88 | # def token_read(): 89 | # try: 90 | # with open(CACHED_TOKEN, "r") as token_file: 91 | # return json.load(token_file) 92 | # except FileNotFoundError: 93 | # return None 94 | 95 | 96 | # def token_write(token): 97 | # with open(CACHED_TOKEN, "w") as token_file: 98 | # json.dump(token, token_file, indent=2) 99 | 100 | 101 | async def async_main(): 102 | args = parse_arguments() 103 | _LOGGER.debug("args: %s", args) 104 | easee = Easee(args.username, args.password) 105 | 106 | if args.chargers: 107 | chargers: List[Charger] = await easee.get_chargers() 108 | await chargers_info(chargers) 109 | 110 | if args.sites: 111 | sites: List[Site] = await easee.get_account_products() 112 | await sites_info(sites) 113 | 114 | if args.circuits: 115 | sites: List[Site] = await easee.get_sites() 116 | for site in sites: 117 | await circuits_info(circuits=site.get_circuits()) 118 | 119 | if args.equalizers: 120 | sites: List[Site] = await easee.get_sites() 121 | for site in sites: 122 | await equalizers_info(equalizers=site.get_equalizers()) 123 | 124 | if args.countries: 125 | countries_active = await easee.get_active_countries() 126 | print(json.dumps(countries_active, indent=2)) 127 | 128 | if args.cost: 129 | dt_end = datetime.now() 130 | dt_start = datetime.now().replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) 131 | sites: List[Site] = await easee.get_sites() 132 | for site in sites: 133 | costs = await site.get_cost_between_dates(dt_start, dt_end) 134 | await costs_info(costs) 135 | 136 | if args.all: 137 | sites: List[Site] = await easee.get_sites() 138 | await sites_info(sites) 139 | for site in sites: 140 | equalizers = site.get_equalizers() 141 | await equalizers_info(equalizers) 142 | circuits = site.get_circuits() 143 | await circuits_info(circuits) 144 | for circuit in circuits: 145 | chargers = circuit.get_chargers() 146 | await chargers_info(chargers) 147 | 148 | if args.summary: 149 | sites: List[Site] = await easee.get_sites() 150 | circuits = List[Circuit] 151 | chargers = List[Charger] 152 | equalizers = List[Equalizer] 153 | 154 | for site in sites: 155 | print( 156 | f" " 157 | f" Site: {site.__getitem__('name')}" 158 | f" (ID: {site.id})," 159 | f" {site.__getitem__('address')['street']}," 160 | f" main fuse {site.__getitem__('ratedCurrent')}A" 161 | f" " 162 | ) 163 | equalizers = site.get_equalizers() 164 | for equalizer in equalizers: 165 | print( 166 | f" " 167 | f" Equalizer: #{equalizer.__getitem__('name')}" 168 | f" (ID: {equalizer.id})" 169 | f" SiteID: #{equalizer.__getitem__('siteId')}" 170 | f" CircuitId: #{equalizer.__getitem__('circuitId')}" 171 | f" " 172 | ) 173 | circuits = site.get_circuits() 174 | for circuit in circuits: 175 | print( 176 | f" " 177 | f" Circuit: #{circuit.__getitem__('circuitPanelId')}" 178 | f" {circuit.__getitem__('panelName')}" 179 | f" (ID: {circuit.id})" 180 | f" {circuit.__getitem__('ratedCurrent')}A" 181 | f" " 182 | ) 183 | chargers = circuit.get_chargers() 184 | for charger in chargers: 185 | state = await charger.get_state() 186 | config = await charger.get_config() 187 | print( 188 | f" " 189 | f" Charger: {charger.__getitem__('name')}" 190 | f" (ID: {charger.id})," 191 | f" enabled: {config.__getitem__('isEnabled')}" 192 | f" online: {state.__getitem__('isOnline')}" 193 | f" version: {state.__getitem__('chargerFirmware')}" 194 | f" voltage: {round(state.__getitem__('voltage'),1)}" 195 | f" current: {round(state.__getitem__('outputCurrent'),1)}" 196 | f" " 197 | ) 198 | 199 | print(f"\n\nFound {len(sites)} site(s), {len(circuits)} circuit(s) and {len(chargers)} charger(s).") 200 | 201 | if args.loop: 202 | sites: List[Site] = await easee.get_sites() 203 | for site in sites: 204 | circuits = site.get_circuits() 205 | for circuit in circuits: 206 | chargers = circuit.get_chargers() 207 | try: 208 | header = True 209 | while True: 210 | for charger in chargers: 211 | await charger_loop(charger, header) 212 | header = False 213 | await asyncio.sleep(5) 214 | except KeyboardInterrupt as e: # noqa 215 | # Close connection on user interuption 216 | print("Interrupted by user") 217 | await easee.close() 218 | except Exception as e: 219 | print(e) 220 | await easee.close() 221 | 222 | if args.force: 223 | chargers: List[Charger] = await easee.get_chargers() 224 | for charger in chargers: 225 | print(f"Forcing update of lifetimeenergy on charger {charger['id']}") 226 | await charger.force_update_lifetimeenergy() 227 | print(f"Forcing update of opmode on charger {charger['id']}") 228 | await charger.force_update_opmode() 229 | 230 | if args.signalr: 231 | chargers: List[Charger] = await easee.get_chargers() 232 | equalizers = [] 233 | sites: List[Site] = await easee.get_sites() 234 | for site in sites: 235 | equalizers_site = site.get_equalizers() 236 | for equalizer in equalizers_site: 237 | equalizers.append(equalizer) 238 | for charger in chargers: 239 | await easee.sr_subscribe(charger, print_signalr) 240 | for equalizer in equalizers: 241 | await easee.sr_subscribe(equalizer, print_signalr) 242 | 243 | queue = asyncio.Queue(1) 244 | input_thread = threading.Thread(target=add_input, args=(queue,)) 245 | input_thread.daemon = True 246 | input_thread.start() 247 | 248 | while True: 249 | await asyncio.sleep(1) 250 | 251 | if queue.empty() is False: 252 | # print "\ninput:", input_queue.get() 253 | break 254 | 255 | if args.ocppurl: 256 | print(args.ocppurl) 257 | chargers: List[Charger] = await easee.get_chargers() 258 | for charger in chargers: 259 | response = await charger.set_ocpp_config(True, args.ocppurl) 260 | print("Version: " + response) 261 | await charger.apply_ocpp_config(response) 262 | response = await charger.get_ocpp_config() 263 | print(response) 264 | 265 | if args.ocppdisable: 266 | chargers: List[Charger] = await easee.get_chargers() 267 | for charger in chargers: 268 | response = await charger.set_ocpp_config(False, "ws://127.0.0.1:9000") 269 | print("Version: " + response) 270 | await charger.apply_ocpp_config(response) 271 | response = await charger.get_ocpp_config() 272 | print(response) 273 | 274 | await easee.close() 275 | 276 | 277 | async def chargers_info(chargers: List[Charger]): 278 | print("\n\n****************\nCHARGERS\n****************") 279 | data = [] 280 | for charger in chargers: 281 | state = await charger.get_state() 282 | config = await charger.get_config() 283 | schedule = await charger.get_basic_charge_plan() 284 | week_schedule = await charger.get_weekly_charge_plan() 285 | observation_test = await charger.get_observations(30, 31, 35, 36, 45) 286 | firmware = await charger.get_latest_firmware() 287 | ocpp = await charger.get_ocpp_config() 288 | ch = charger.get_data() 289 | ch["state"] = state.get_data() 290 | ch["config"] = config.get_data() 291 | ch["firmware"] = firmware 292 | ch["observation"] = observation_test 293 | if schedule is not None: 294 | ch["schedule"] = schedule.get_data() 295 | if week_schedule is not None: 296 | ch["week_schedule"] = week_schedule.get_data() 297 | ch["ocpp"] = ocpp 298 | data.append(ch) 299 | 300 | print(json.dumps(data, indent=2)) 301 | 302 | 303 | async def sites_info(sites: List[Site]): 304 | print("\n\n****************\nSITES\n****************") 305 | data = [] 306 | for site in sites: 307 | data.append(site.get_data()) 308 | 309 | print(json.dumps(data, indent=2)) 310 | 311 | 312 | async def circuits_info(circuits: List[Circuit]): 313 | print("\n\n****************\nCIRCUITS\n****************") 314 | data = [] 315 | for circuit in circuits: 316 | data.append(circuit.get_data()) 317 | 318 | print(json.dumps(data, indent=2)) 319 | 320 | 321 | async def equalizers_info(equalizers: List[Equalizer]): 322 | print("\n\n****************\nEQUALIZERS\n****************") 323 | data = [] 324 | for equalizer in equalizers: 325 | eq = equalizer.get_data() 326 | state = await equalizer.get_state() 327 | config = await equalizer.get_config() 328 | eq["state"] = state.get_data() 329 | eq["config"] = config.get_data() 330 | data.append(eq) 331 | 332 | print( 333 | json.dumps( 334 | data, 335 | indent=2, 336 | ) 337 | ) 338 | 339 | 340 | async def costs_info(costs): 341 | print("\n\n****************\nCOST\n****************") 342 | data = [] 343 | for cost in costs: 344 | data.append(cost["chargerId"]) 345 | data.append(cost["totalCost"]) 346 | data.append(cost["currencyId"]) 347 | data.append(cost["totalEnergyUsage"]) 348 | 349 | print(json.dumps(data, indent=2)) 350 | 351 | 352 | async def charger_loop(charger: Charger, header=False): 353 | """Return the state attributes.""" 354 | # await charger.async_update() 355 | state = await charger.get_state() 356 | # config = await charger.get_config() # not used yet 357 | 358 | if header: 359 | print(str_fixed_length("NAME", 15), end=" ") 360 | print(str_fixed_length("OPMODE", 20), end=" ") 361 | print(str_fixed_length("ONLINE", 7), end=" ") 362 | print(str_fixed_length("POWER", 7), end=" ") 363 | print(str_fixed_length("OUTCURR", 10), end=" ") 364 | print(str_fixed_length("IN_T2", 10), end=" ") 365 | print(str_fixed_length("IN_T3", 10), end=" ") 366 | print(str_fixed_length("IN_T4", 10), end=" ") 367 | print(str_fixed_length("IN_T5", 10), end=" ") 368 | print(str_fixed_length("VOLTAGE", 10), end=" ") 369 | print(str_fixed_length("kWh", 10), end=" ") 370 | print(str_fixed_length("RATE", 10), end=" ") 371 | print(str_fixed_length("REASON", 25), end=" ") 372 | print(" ") 373 | 374 | print(str_fixed_length(f"{charger.name}", 15), end=" ") 375 | print(str_fixed_length(f"{state.__getitem__('chargerOpMode')}", 20), end=" ") 376 | print(str_fixed_length(f"{state.__getitem__('isOnline')}", 7), end=" ") 377 | print(str_fixed_length(f"{round(state.__getitem__('totalPower'),2)}kW", 7), end=" ") 378 | print(str_fixed_length(f"{round(state.__getitem__('outputCurrent'),1)}A", 10), end=" ") 379 | print(str_fixed_length(f"{round(state.__getitem__('inCurrentT2'),1)}A", 10), end=" ") 380 | print(str_fixed_length(f"{round(state.__getitem__('inCurrentT3'),1)}A", 10), end=" ") 381 | print(str_fixed_length(f"{round(state.__getitem__('inCurrentT4'),1)}A", 10), end=" ") 382 | print(str_fixed_length(f"{round(state.__getitem__('inCurrentT5'),1)}A", 10), end=" ") 383 | print(str_fixed_length(f"{round(state.__getitem__('voltage'),1)}V", 10), end=" ") 384 | print( 385 | str_fixed_length(f"{round(state.__getitem__('sessionEnergy'),2)}kWh", 10), 386 | end=" ", 387 | ) 388 | print( 389 | str_fixed_length(f"{round(state.__getitem__('energyPerHour'),2)}kWh/h", 10), 390 | end=" ", 391 | ) 392 | print(str_fixed_length(f"{str(state.__getitem__('reasonForNoCurrent'))}", 25), end=" ") 393 | print(" ") 394 | 395 | 396 | def str_fixed_length(myStr, length: int): 397 | while len(myStr) < length: 398 | myStr = myStr + " " 399 | if len(myStr) > length: 400 | myStr = myStr[0:length] 401 | return myStr 402 | 403 | 404 | def main(): 405 | asyncio.run(async_main()) 406 | 407 | 408 | if __name__ == "__main__": 409 | import time 410 | 411 | s = time.perf_counter() 412 | main() 413 | elapsed = time.perf_counter() - s 414 | print(f"{__file__} executed in {elapsed:0.2f} seconds.") 415 | -------------------------------------------------------------------------------- /pyeasee/easee.py: -------------------------------------------------------------------------------- 1 | """ 2 | Main client for the Eesee account. 3 | """ 4 | import asyncio 5 | from datetime import datetime, timedelta 6 | import logging 7 | import ssl 8 | from typing import Any, AsyncIterator, Dict, List 9 | 10 | import aiohttp 11 | import pysignalr 12 | from pysignalr.client import SignalRClient 13 | from pysignalr.exceptions import AuthorizationError 14 | from pysignalr.messages import CompletionMessage 15 | import websockets.asyncio.client 16 | 17 | from .charger import Charger 18 | from .exceptions import ( 19 | AuthorizationFailedException, 20 | BadRequestException, 21 | ForbiddenServiceException, 22 | NotFoundException, 23 | ServerFailureException, 24 | TooManyRequestsException, 25 | ) 26 | from .site import Site, SiteState 27 | from .throttler import Throttler 28 | from .utils import convert_stream_data 29 | 30 | __VERSION__ = "0.8.15" 31 | 32 | _LOGGER = logging.getLogger(__name__) 33 | 34 | SR_MIN_BACKOFF = 0 35 | SR_MAX_BACKOFF = 300 36 | SR_INC_BACKOFF = 30 37 | 38 | 39 | async def raise_for_status(response): 40 | if 400 <= response.status: 41 | e = aiohttp.ClientResponseError( 42 | response.request_info, 43 | response.history, 44 | code=response.status, 45 | headers=response.headers, 46 | ) 47 | 48 | if "json" in response.headers.get("CONTENT-TYPE", ""): 49 | data = await response.json() 50 | e.message = str(data) 51 | else: 52 | data = await response.text() 53 | 54 | if 400 == response.status: 55 | _LOGGER.debug("Bad request " + f"({response.status}: {data} {response.url})") 56 | raise BadRequestException(data) 57 | elif 401 == response.status: 58 | _LOGGER.debug("Unautorized " + f"({response.status}: {data} {response.url})") 59 | raise AuthorizationFailedException(data) 60 | elif 403 == response.status: 61 | _LOGGER.debug("Forbidden service" + f"({response.status}: {response.url})") 62 | raise ForbiddenServiceException(data) 63 | elif 404 == response.status: 64 | # Getting this error when getting or deleting charge schedules which doesn't exist (empty) 65 | _LOGGER.debug("Not found " + f"({response.status}: {data} {response.url})") 66 | raise NotFoundException(data) 67 | elif 429 == response.status: 68 | _LOGGER.debug("Too many requests " + f"({response.status}: {data} {response.url})") 69 | raise TooManyRequestsException(data, response.headers.get("retry-after")) 70 | elif response.status >= 500: 71 | _LOGGER.warning("Server failure" + f"({response.status}: {response.url})") 72 | raise ServerFailureException(data) 73 | else: 74 | _LOGGER.error("Error in request to Easee API: %s", data) 75 | raise Exception(data) from e 76 | return False 77 | else: 78 | return True 79 | 80 | 81 | async def __aiter__( 82 | self: websockets.asyncio.client.connect, 83 | ) -> AsyncIterator[websockets.asyncio.client.ClientConnection]: 84 | """ 85 | Asynchronous iterator for the websocket Connect object. 86 | This function overrides the error handling put in place in pysignalr so that exception propagates out. 87 | """ 88 | while True: 89 | async with self as protocol: 90 | yield protocol 91 | 92 | 93 | class Easee: 94 | def __init__( 95 | self, 96 | username, 97 | password, 98 | session: aiohttp.ClientSession | None = None, 99 | user_agent=None, 100 | ssl: ssl.SSLContext | None = None, 101 | ): 102 | self.username = username 103 | self.password = password 104 | self.external_session = True if session else False 105 | if user_agent is None: 106 | append_user_agent = "" 107 | else: 108 | append_user_agent = f"; {user_agent}" 109 | self._ssl = ssl 110 | 111 | _LOGGER.info("Easee python library version: %s", __VERSION__) 112 | 113 | self.base = "https://api.easee.com" 114 | self.sr_base = "https://streams.easee.com/hubs/chargers" 115 | self.token = {} 116 | self.get_headers = { 117 | "User-Agent": f"pyeasee/{__VERSION__} REST client{append_user_agent}", 118 | "Accept": "application/json", 119 | } 120 | self.headers = { 121 | "User-Agent": f"pyeasee/{__VERSION__} REST client{append_user_agent}", 122 | "Accept": "application/json", 123 | "Content-Type": "application/json;charset=UTF-8", 124 | } 125 | self.minimal_headers = self.headers 126 | self.sr_headers = { 127 | "User-Agent": f"pyeasee/{__VERSION__} SignalR client{append_user_agent}", 128 | "Accept": "application/json", 129 | "Content-Type": "application/json;charset=UTF-8", 130 | } 131 | if session is None: 132 | self.session = aiohttp.ClientSession() 133 | else: 134 | self.session = session 135 | 136 | self.sr_subscriptions = {} 137 | self.sr_connection = None 138 | self.sr_connected = False 139 | self.sr_connect_in_progress = False 140 | self._sr_backoff = SR_MIN_BACKOFF 141 | self._sr_task = None 142 | 143 | self._general_throttler = Throttler(rate_limit=500, period=300, name="general") 144 | self._sites_throttler = Throttler(rate_limit=10, period=3600, name="sites") 145 | 146 | # Override the __aiter__ method of the pysignalr.websocket Connect class 147 | pysignalr.websockets.asyncio.client.connect.__aiter__ = __aiter__ # type: ignore[method-assign] 148 | 149 | def base_uri(self): 150 | return self.base 151 | 152 | async def post(self, url, **kwargs): 153 | _LOGGER.debug("POST: %s (%s)", url, kwargs) 154 | await self._verify_updated_token() 155 | async with self._general_throttler: 156 | response = await self.session.post(f"{self.base}{url}", headers=self.headers, **kwargs) 157 | await self.check_status(response) 158 | return response 159 | 160 | async def put(self, url, **kwargs): 161 | _LOGGER.debug("PUT: %s (%s)", url, kwargs) 162 | await self._verify_updated_token() 163 | async with self._general_throttler: 164 | response = await self.session.put(f"{self.base}{url}", headers=self.headers, **kwargs) 165 | await self.check_status(response) 166 | return response 167 | 168 | async def get(self, url, **kwargs): 169 | _LOGGER.debug("GET: %s (%s)", url, kwargs) 170 | await self._verify_updated_token() 171 | async with self._general_throttler: 172 | response = await self.session.get(f"{self.base}{url}", headers=self.get_headers, **kwargs) 173 | await self.check_status(response) 174 | return response 175 | 176 | async def delete(self, url, **kwargs): 177 | _LOGGER.debug("DELETE: %s (%s)", url, kwargs) 178 | await self._verify_updated_token() 179 | async with self._general_throttler: 180 | response = await self.session.delete(f"{self.base}{url}", headers=self.headers, **kwargs) 181 | await self.check_status(response) 182 | return response 183 | 184 | async def check_status(self, response): 185 | try: 186 | await raise_for_status(response) 187 | except (AuthorizationFailedException, BadRequestException): 188 | _LOGGER.debug("Re authorizing due to 401") 189 | await self.connect() 190 | # rethrow it 191 | await raise_for_status(response) 192 | except ForbiddenServiceException: 193 | raise 194 | except Exception as ex: 195 | _LOGGER.debug("Got other exception from status: %s", type(ex).__name__) 196 | raise 197 | 198 | async def _verify_updated_token(self): 199 | """ 200 | Make sure there is a valid token 201 | """ 202 | if "accessToken" not in self.token: 203 | await self.connect() 204 | _LOGGER.debug( 205 | "verify_updated_token: %s, %s, %s", 206 | self.token["expires"], 207 | datetime.now(), 208 | self.token["expires"] < datetime.now(), 209 | ) 210 | if self.token["expires"] < datetime.now(): 211 | await self._refresh_token() 212 | accessToken = self.token["accessToken"] 213 | self.headers["Authorization"] = f"Bearer {accessToken}" 214 | self.get_headers["Authorization"] = f"Bearer {accessToken}" 215 | self.sr_headers["Authorization"] = f"Bearer {accessToken}" 216 | 217 | async def _handle_token_response(self, res): 218 | """ 219 | Handle the token request and set new datetime when it expires 220 | """ 221 | self.token = await res.json() 222 | _LOGGER.debug("TOKEN: %s", self.token) 223 | expiresIn = int(self.token["expiresIn"]) - 60 224 | now = datetime.now() 225 | self.token["expires"] = now + timedelta(0, expiresIn) 226 | 227 | async def connect(self): 228 | """ 229 | Connect and gets initial token 230 | """ 231 | data = {"userName": self.username, "password": self.password} 232 | _LOGGER.debug("getting token for user: %s", self.username) 233 | response = await self.session.post(f"{self.base}/api/accounts/login", headers=self.minimal_headers, json=data) 234 | await raise_for_status(response) 235 | await self._handle_token_response(response) 236 | 237 | async def _refresh_token(self): 238 | """ 239 | Refresh token 240 | """ 241 | data = { 242 | "accessToken": self.token["accessToken"], 243 | "refreshToken": self.token["refreshToken"], 244 | } 245 | _LOGGER.debug("Refreshing access token") 246 | try: 247 | res = await self.session.post( 248 | f"{self.base}/api/accounts/refresh_token", headers=self.minimal_headers, json=data 249 | ) 250 | await raise_for_status(res) 251 | await self._handle_token_response(res) 252 | except (AuthorizationFailedException, BadRequestException): 253 | _LOGGER.debug("Could not get new access token from refresh token, getting new one") 254 | await self.connect() 255 | 256 | async def close(self): 257 | """ 258 | Close the underlying aiohttp session 259 | """ 260 | if self.session and self.external_session is False: 261 | await self.session.close() 262 | self.session = None 263 | 264 | await self._sr_disconnect() 265 | 266 | def _sr_next(self): 267 | if self._sr_backoff < SR_MAX_BACKOFF: 268 | self._sr_backoff = self._sr_backoff + SR_INC_BACKOFF 269 | return self._sr_backoff 270 | 271 | async def _sr_open_cb(self) -> None: 272 | """ 273 | Signalr connected callback - called from signalr thread, internal use only 274 | """ 275 | _LOGGER.info("SR stream connected") 276 | self._sr_backoff = SR_MIN_BACKOFF 277 | self.sr_connected = True 278 | 279 | for id in self.sr_subscriptions: 280 | _LOGGER.debug("Subscribing to %s", id) 281 | await self.sr_connection.send("SubscribeWithCurrentState", [id, True]) 282 | 283 | async def _sr_close_cb(self) -> None: 284 | """ 285 | Signalr disconnected callback - called from signalr thread, internal use only 286 | """ 287 | _LOGGER.error("SR stream disconnected or failed to connect") 288 | self.sr_connected = False 289 | 290 | async def _sr_error_cb(self, message: CompletionMessage) -> None: 291 | _LOGGER.error("SR error recevied {message.error}") 292 | 293 | async def _sr_product_update_cb(self, stuff: List[Dict[str, Any]]) -> None: 294 | """ 295 | Signalr new data recieved callback - called from signalr thread, internal use only 296 | """ 297 | self._sr_last_data = datetime.now() 298 | for data in stuff: 299 | await self._sr_callback(data) 300 | 301 | async def _sr_callback(self, stuff: List[Dict[str, Any]]): 302 | """ 303 | Signalr callback handler - internal use only 304 | """ 305 | if stuff["mid"] in self.sr_subscriptions: 306 | callback = self.sr_subscriptions[stuff["mid"]] 307 | value = convert_stream_data(stuff["dataType"], stuff["value"]) 308 | await callback(stuff["mid"], stuff["dataType"], stuff["id"], value) 309 | else: 310 | _LOGGER.error("No callback found for '%s'", stuff["mid"]) 311 | 312 | async def _sr_connect(self, start_delay=0): 313 | """ 314 | Signalr connect - internal use only 315 | """ 316 | if self.sr_connect_in_progress is True: 317 | _LOGGER.debug("SR already connecting") 318 | return 319 | self.sr_connect_in_progress = True 320 | 321 | _LOGGER.debug(f"SR connect sleep {start_delay}") 322 | await asyncio.sleep(start_delay) 323 | 324 | self._sr_task = asyncio.create_task(self._sr_connect_loop(), name="pyeasee signalr task") 325 | 326 | async def _sr_connect_loop(self): 327 | """ 328 | Signalr connect loop - internal use only 329 | """ 330 | if self.sr_connection is not None: 331 | return 332 | 333 | _LOGGER.debug("SR connect loop") 334 | 335 | while True: 336 | try: 337 | await self._verify_updated_token() 338 | self.sr_connection = SignalRClient(self.sr_base, headers=self.sr_headers, ssl=self._ssl) 339 | self.sr_connection.on_open(self._sr_open_cb) 340 | self.sr_connection.on_close(self._sr_close_cb) 341 | self.sr_connection.on_error(self._sr_error_cb) 342 | self.sr_connection.on("ProductUpdate", self._sr_product_update_cb) 343 | _LOGGER.debug("SR run") 344 | await self.sr_connection.run() 345 | except AuthorizationError as ex: 346 | self.sr_connected = False 347 | backoff = self._sr_next() 348 | _LOGGER.error("SR authentication failed: %s. Retry in %d seconds", type(ex).__name__, backoff) 349 | await asyncio.sleep(backoff) 350 | await self._refresh_token() 351 | continue 352 | except Exception as ex: 353 | self.sr_connected = False 354 | backoff = self._sr_next() 355 | _LOGGER.error("SR start exception: %s: %s. Retry in %d seconds", type(ex).__name__, ex, backoff) 356 | await asyncio.sleep(backoff) 357 | continue 358 | except asyncio.CancelledError: 359 | _LOGGER.debug("SR task cancelled (self)") 360 | 361 | break 362 | 363 | self.sr_connect_in_progress = False 364 | 365 | def sr_is_connected(self): 366 | return self.sr_connected 367 | 368 | async def sr_subscribe(self, product, callback): 369 | """ 370 | Subscribe to signalr events for product, callback will be called as async callback(product_id, data_type, data_id, value) 371 | """ 372 | if product.id in self.sr_subscriptions: 373 | return 374 | 375 | _LOGGER.debug("Subscribing to %s", product.id) 376 | self.sr_subscriptions[product.id] = callback 377 | if self.sr_connected is True: 378 | await self.sr_connection.send("SubscribeWithCurrentState", [product.id, True]) 379 | else: 380 | await self._sr_connect() 381 | 382 | async def sr_unsubscribe(self, product): 383 | """ 384 | Unsubscribe from signalr events for product 385 | BUG: Does not work 386 | """ 387 | _LOGGER.debug("Unsubscribing from %s", product.id) 388 | if product.id in self.sr_subscriptions: 389 | del self.sr_subscriptions[product.id] 390 | await self._sr_disconnect() 391 | await self._sr_connect() 392 | 393 | async def _sr_disconnect(self): 394 | """ 395 | Disconnect the signalr stream - internal use only 396 | """ 397 | if self._sr_task is not None: 398 | self._sr_task.cancel() 399 | try: 400 | await self._sr_task 401 | except asyncio.CancelledError: 402 | _LOGGER.debug("SR task cancelled") 403 | self._sr_task = None 404 | self.sr_connection = None 405 | self.sr_connect_in_progress = False 406 | 407 | async def get_chargers(self) -> List[Charger]: 408 | """ 409 | Retrieve all chargers 410 | """ 411 | try: 412 | records = await (await self.get("/api/chargers")).json() 413 | _LOGGER.debug("Chargers: %s", records) 414 | return [Charger(k, self) for k in records] 415 | except (ServerFailureException): 416 | return None 417 | 418 | async def get_site(self, id: int) -> Site: 419 | """get site by id""" 420 | try: 421 | async with self._sites_throttler: 422 | data = await (await self.get(f"/api/sites/{id}?detailed=true")).json() 423 | _LOGGER.debug("Site: %s", data) 424 | return Site(data, self) 425 | except (ServerFailureException): 426 | return None 427 | 428 | async def get_sites(self) -> List[Site]: 429 | """Get all sites""" 430 | try: 431 | records = await (await self.get("/api/sites")).json() 432 | _LOGGER.debug("Sites: %s", records) 433 | sites = await asyncio.gather(*[self.get_site(r["id"]) for r in records]) 434 | return sites 435 | except (ServerFailureException): 436 | return None 437 | 438 | async def get_account_products(self) -> List[Site]: 439 | """Get all sites and products that are accessible by the logged in user""" 440 | try: 441 | records = await (await self.get("/api/accounts/products")).json() 442 | _LOGGER.debug("Sites: %s", records) 443 | sites = [] 444 | for r in records: 445 | site = await self.get_site(r["id"]) 446 | site["circuits"] = r["circuits"] 447 | site["equalizers"] = r["equalizers"] 448 | sites.append(site) 449 | return sites 450 | except (ServerFailureException): 451 | return None 452 | 453 | async def get_site_state(self, id: str) -> SiteState: 454 | """Get site state""" 455 | try: 456 | state = await (await self.get(f"/api/sites/{id}/state")).json() 457 | return SiteState(state) 458 | except (ServerFailureException): 459 | return None 460 | 461 | async def get_active_countries(self) -> List[Any]: 462 | """Get all active countries""" 463 | try: 464 | records = await (await self.get("/api/resources/countries/active")).json() 465 | _LOGGER.debug("Active countries: %s", records) 466 | return records 467 | except (ServerFailureException): 468 | return None 469 | 470 | async def get_currencies(self) -> List[Any]: 471 | """Get all currencies""" 472 | try: 473 | records = await (await self.get("/api/resources/currencies")).json() 474 | _LOGGER.debug("Currencies: %s", records) 475 | return records 476 | except (ServerFailureException): 477 | return None 478 | -------------------------------------------------------------------------------- /html/pyeasee/site.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | pyeasee.site API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 |
22 |

Module pyeasee.site

23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |

Classes

34 |
35 |
36 | class Circuit 37 | (data: Dict[str, Any], site: Any, easee: Any) 38 |
39 |
40 |

Represents a Circuit

41 |

Ancestors

42 |
    43 |
  • BaseDict
  • 44 |
  • collections.abc.Mapping
  • 45 |
  • collections.abc.Collection
  • 46 |
  • collections.abc.Sized
  • 47 |
  • collections.abc.Iterable
  • 48 |
  • collections.abc.Container
  • 49 |
50 |

Methods

51 |
52 |
53 | def get_chargers(self) ‑> List[Charger] 54 |
55 |
56 |
57 |
58 |
59 | async def set_dynamic_current(self, currentP1: int, currentP2: int = None, currentP3: int = None, timeToLive: int = 0) 60 |
61 |
62 |

Set dynamic current on circuit level. timeToLive specifies, in minutes, for how long the new dynamic current is valid. timeToLive = 0 means that the new dynamic current is valid until changed the next time. The dynamic current is always reset to default when the charger is restarted

63 |
64 |
65 | async def set_max_current(self, currentP1: int, currentP2: int = None, currentP3: int = None) 66 |
67 |
68 |

Set circuit max current

69 |
70 |
71 | async def set_max_offline_current(self, currentP1: int, currentP2: int = None, currentP3: int = None) 72 |
73 |
74 |

Set circuit max offline current, fallback value for limit if charger is offline

75 |
76 |
77 | async def set_rated_current(self, ratedCurrentFuseValue: int) 78 |
79 |
80 |

Set circuit rated current - requires elevated access (installers only)

81 |
82 |
83 |
84 |
85 | class Equalizer 86 | (data: Dict[str, Any], site: Any, easee: Any) 87 |
88 |
89 |

A Mapping is a generic container for associating key/value 90 | pairs.

91 |

This class provides concrete generic implementations of all 92 | methods except for getitem, iter, and len.

93 |

Ancestors

94 |
    95 |
  • BaseDict
  • 96 |
  • collections.abc.Mapping
  • 97 |
  • collections.abc.Collection
  • 98 |
  • collections.abc.Sized
  • 99 |
  • collections.abc.Iterable
  • 100 |
  • collections.abc.Container
  • 101 |
102 |

Methods

103 |
104 |
105 | async def empty_config(self, raw=False) 106 |
107 |
108 |

Crate an empty config data structure

109 |
110 |
111 | async def empty_state(self, raw=False) 112 |
113 |
114 |

Create an empty state data structyre

115 |
116 |
117 | async def get_config(self) 118 |
119 |
120 |

Get Equalizer config

121 |
122 |
123 | async def get_latest_firmware(self) 124 |
125 |
126 |

Get the latest released firmeware version

127 |
128 |
129 | async def get_load_balancing(self) 130 |
131 |
132 |

Get the load balancing settings

133 |
134 |
135 | async def get_observations(self, *args) 136 |
137 |
138 |

Gets observation IDs

139 |
140 |
141 | async def get_state(self) 142 |
143 |
144 |

Get Equalizer state

145 |
146 |
147 | async def set_load_balancing(self, activate: bool, current_limit: int = 0) 148 |
149 |
150 |

Set the load balancing settings

151 |
152 |
153 | async def set_max_allocated_current(self, current_limit: int) 154 |
155 |
156 |

Set the load balancing settings

157 |
158 |
159 |
160 |
161 | class EqualizerConfig 162 | (config: Dict[str, Any]) 163 |
164 |
165 |

A Mapping is a generic container for associating key/value 166 | pairs.

167 |

This class provides concrete generic implementations of all 168 | methods except for getitem, iter, and len.

169 |

Ancestors

170 |
    171 |
  • BaseDict
  • 172 |
  • collections.abc.Mapping
  • 173 |
  • collections.abc.Collection
  • 174 |
  • collections.abc.Sized
  • 175 |
  • collections.abc.Iterable
  • 176 |
  • collections.abc.Container
  • 177 |
178 |
179 |
180 | class EqualizerState 181 | (state: Dict[str, Any]) 182 |
183 |
184 |

A Mapping is a generic container for associating key/value 185 | pairs.

186 |

This class provides concrete generic implementations of all 187 | methods except for getitem, iter, and len.

188 |

Ancestors

189 |
    190 |
  • BaseDict
  • 191 |
  • collections.abc.Mapping
  • 192 |
  • collections.abc.Collection
  • 193 |
  • collections.abc.Sized
  • 194 |
  • collections.abc.Iterable
  • 195 |
  • collections.abc.Container
  • 196 |
197 |
198 |
199 | class Site 200 | (data: Dict[str, Any], easee: Any) 201 |
202 |
203 |

Represents a Site

204 |

Ancestors

205 |
    206 |
  • BaseDict
  • 207 |
  • collections.abc.Mapping
  • 208 |
  • collections.abc.Collection
  • 209 |
  • collections.abc.Sized
  • 210 |
  • collections.abc.Iterable
  • 211 |
  • collections.abc.Container
  • 212 |
213 |

Methods

214 |
215 |
216 | def get_circuits(self) ‑> List[Circuit] 217 |
218 |
219 |

Get circuits for the site

220 |
221 |
222 | async def get_cost_between_dates(self, from_date: datetime.datetime, to_date: datetime.datetime) 223 |
224 |
225 |

Get the charging cost between from_datetime and to_datetime

226 |
227 |
228 | def get_equalizers(self) ‑> List[Equalizer] 229 |
230 |
231 |

Get equalizers for the site

232 |
233 |
234 | async def get_user_monthly_consumption(self, user_id) 235 |
236 |
237 |

Get user consumption

238 |
239 |
240 | async def get_user_yearly_consumption(self, user_id) 241 |
242 |
243 |

Get user consumption

244 |
245 |
246 | async def get_users(self) 247 |
248 |
249 |

Get a list of users connected to this site

250 |
251 |
252 | async def set_currency(self, currency: str) 253 |
254 |
255 |

Set currency for the site

256 |
257 |
258 | async def set_name(self, name: str) 259 |
260 |
261 |

Set name for the site

262 |
263 |
264 | async def set_price(self, costPerKWh: float, vat: float = None, currency: str = None, costPerKwhExcludeVat: float = None) 265 |
266 |
267 |

Set price per kWh for the site

268 |
269 |
270 |
271 |
272 | class SiteState 273 | (data: Dict[str, Any]) 274 |
275 |
276 |

Represents the site state as received through /sites//state

277 |

Ancestors

278 |
    279 |
  • BaseDict
  • 280 |
  • collections.abc.Mapping
  • 281 |
  • collections.abc.Collection
  • 282 |
  • collections.abc.Sized
  • 283 |
  • collections.abc.Iterable
  • 284 |
  • collections.abc.Container
  • 285 |
286 |

Methods

287 |
288 |
289 | def get_charger_config(self, charger_id: str, raw=False) ‑> ChargerConfig 290 |
291 |
292 |

get config for charger from the instance data

293 |
294 |
295 | def get_charger_state(self, charger_id: str, raw=False) ‑> ChargerState 296 |
297 |
298 |

get state for charger from the instance data

299 |
300 |
301 |
302 |
303 |
304 |
305 | 373 |
374 | 377 | 378 | -------------------------------------------------------------------------------- /pyeasee/const.py: -------------------------------------------------------------------------------- 1 | # Data IDs for Charger from signalr stream 2 | 3 | from enum import Enum 4 | 5 | 6 | class ChargerStreamData(Enum): 7 | state_selfTestResult = 1 # OK or error codes [String] ['Admin', 'Partner', 'User'] 8 | state_selfTestDetails = 2 # JSON with details from self-test [String] ['Admin', 'Partner'] 9 | state_debugLog = 5 10 | state_wifiEvent = 10 # Enum with WiFi event codes. Requires telemetry debug mode. Will be updated on WiFi events when using cellular, will otherwise be reported in ChargerOfflineReason [Integer] ['Admin', 'Partner'] 11 | state_chargerOfflineReason = 11 # Enum describing why charger is offline [Integer] ['Admin', 'Partner', 'User'] 12 | state_easeeLinkCommandResponse = ( 13 | 13 # Response on a EaseeLink command sent to another devic [Integer] ['Admin', 'Partner'] 14 | ) 15 | state_easeeLinkDataReceived = 14 # Data received on EaseeLink from another device [String] ['Admin', 'Partner'] 16 | config_localPreAuthorizeEnabled = ( 17 | 15 # Preauthorize with whitelist enabled. Readback on setting [event] [Boolean] ['Admin', 'Partner', 'User'] 18 | ) 19 | config_localAuthorizeOfflineEnabled = 16 # Allow offline charging for whitelisted RFID token. Readback on setting [event] [Boolean] ['Admin', 'Partner', 'User'] 20 | config_allowOfflineTxForUnknownId = 17 # Allow offline charging for all RFID tokens. Readback on setting [event] [Boolean] ['Admin', 'Partner', 'User'] 21 | config_erraticEVMaxToggles = 18 # 0 == erratic checking disabled, otherwise the number of toggles between states Charging and Charging Complate that will trigger an error [Integer] ['Admin', 'Partner'] 22 | config_backplateType = 19 # Readback on backplate type [Integer] ['Admin', 'Partner'] 23 | config_siteStructure = 20 # Site Structure [boot] [String] ['Admin', 'Partner'] 24 | config_detectedPowerGridType = ( 25 | 21 # Detected power grid type according to PowerGridType table [boot] [Integer] ['Admin', 'Partner', 'User'] 26 | ) 27 | config_circuitMaxCurrentP1 = 22 # Set circuit maximum current [Amperes] [Double] ['Admin', 'Partner', 'User'] 28 | config_circuitMaxCurrentP2 = 23 # Set circuit maximum current [Amperes] [Double] ['Admin', 'Partner', 'User'] 29 | config_circuitMaxCurrentP3 = 24 # Set circuit maximum current [Amperes] [Double] ['Admin', 'Partner', 'User'] 30 | config_location = 25 # Location coordinate [event] [Position] ['Admin', 'Partner'] 31 | config_siteIDString = 26 # Site ID string [event] [String] ['Admin', 'Partner'] 32 | config_siteIDNumeric = 27 # Site ID numeric value [event] [Integer] ['Admin', 'Partner'] 33 | config_rfidAuthTimeoutSec = 28 # Timeout for backend to send authorization reply. After timeout offline rules apply. [Integer] ['Admin', 'Partner'] 34 | state_lockCablePermanently = 30 # Lock type2 cable permanently [Boolean] ['Admin', 'Partner', 'User'] 35 | config_isEnabled = 31 # Set true to enable charger, false disables charger [Boolean] ['Admin', 'Partner', 'User'] 36 | state_temperatureMonitorState = ( 37 | 32 # State of the monitor: OFF=0, MONITORING=-1, ACTIVE=1 [Integer] ['Admin', 'Partner'] 38 | ) 39 | config_circuitSequenceNumber = 33 # Charger sequence number on circuit [Integer] ['Admin', 'Partner'] 40 | config_singlePhaseNumber = 34 # Phase to use in 1-phase charging [Integer] ['Admin', 'Partner'] 41 | config_enable3PhasesDEPRECATED = 35 # Allow charging using 3-phases [Boolean] ['Admin', 'Partner', 'User'] 42 | config_wiFiSSID = 36 # WiFi SSID name [String] ['Admin', 'Partner', 'User'] 43 | config_enableIdleCurrent = 37 # Charger signals available current when EV is done charging [user option][event] [Boolean] ['Admin', 'Partner', 'User'] 44 | config_phaseMode = 38 # Phase mode on this charger. 1-Locked to 1-Phase, 2-Auto, 3-Locked to 3-phase(only Home) [Integer] ['Admin', 'Partner', 'User'] 45 | config_forcedThreePhaseOnITWithGndFault = 39 # Default disabled. Must be set manually if grid type is indeed three phase IT [Boolean] ['Admin', 'Partner'] 46 | config_ledStripBrightness = 40 # LED strip brightness, 0-100% [Integer] ['Admin', 'Partner', 'User'] 47 | config_localAuthorizationRequired = 41 # Local RFID authorization is required for charging [user options][event] [Boolean] ['Admin', 'Partner', 'User'] 48 | config_authorizationRequired = 42 # Authorization is requried for charging [Boolean] ['Admin', 'Partner', 'User'] 49 | config_remoteStartRequired = 43 # Remote start required flag [event] [Boolean] ['Admin', 'Partner', 'User'] 50 | config_smartButtonEnabled = 44 # Smart button is enabled [Boolean] ['Admin', 'Partner', 'User'] 51 | config_offlineChargingMode = 45 # Charger behavior when offline [Integer] ['Admin', 'Partner', 'User'] 52 | state_ledMode = 46 # Charger LED mode [event] [Integer] ['Admin', 'Partner', 'User'] 53 | config_maxChargerCurrent = 47 # Max current this charger is allowed to offer to car (A). Non volatile. [Double] ['Admin', 'Partner', 'User'] 54 | state_dynamicChargerCurrent = ( 55 | 48 # Max current this charger is allowed to offer to car (A). Volatile [Double] ['Admin', 'Partner', 'User'] 56 | ) 57 | state_offlineMaxCircuitCurrentP1 = ( 58 | 50 # Maximum circuit current P1 when offline [event] [Integer] ['Admin', 'Partner', 'User'] 59 | ) 60 | state_offlineMaxCircuitCurrentP2 = ( 61 | 51 # Maximum circuit current P2 when offline [event] [Integer] ['Admin', 'Partner', 'User'] 62 | ) 63 | state_offlineMaxCircuitCurrentP3 = ( 64 | 52 # Maximum circuit current P3 when offline [event] [Integer] ['Admin', 'Partner', 'User'] 65 | ) 66 | config_releaseCableAtPowerOff = 54 # Release Cable At Power Off [Boolean] ['Admin', 'Partner'] 67 | config_listenToControlPulse = 56 # True = charger needs control pulse to consider itself online. Readback on charger setting [event] [Boolean] ['Admin', 'Partner'] 68 | config_controlPulseRTT = 57 # Control pulse round-trip time in milliseconds [Integer] ['Admin', 'Partner'] 69 | schedule_chargingSchedule = 62 # Charging schedule [json] [String] ['Admin', 'Partner', 'User'] 70 | config_pairedEqualizer = 65 # Paired equalizer details [String] ['Admin', 'Partner'] 71 | state_wiFiAPEnabled = ( 72 | 68 # True if WiFi Access Point is enabled, otherwise false [Boolean] ['Admin', 'Partner', 'User'] 73 | ) 74 | state_pairedUserIDToken = ( 75 | 69 # Observed user token when charger put in RFID pairing mode [event] [String] ['Admin', 'Partner', 'User'] 76 | ) 77 | state_circuitTotalAllocatedPhaseConductorCurrentL1 = 70 # Total current allocated to L1 by all chargers on the circuit. Sent in by master only [Double] ['Admin', 'Partner', 'User'] 78 | state_circuitTotalAllocatedPhaseConductorCurrentL2 = 71 # Total current allocated to L2 by all chargers on the circuit. Sent in by master only [Double] ['Admin', 'Partner', 'User'] 79 | state_circuitTotalAllocatedPhaseConductorCurrentL3 = 72 # Total current allocated to L3 by all chargers on the circuit. Sent in by master only [Double] ['Admin', 'Partner', 'User'] 80 | state_circuitTotalPhaseConductorCurrentL1 = 73 # Total current in L1 (sum of all chargers on the circuit) Sent in by master only [Double] ['Admin', 'Partner', 'User'] 81 | state_circuitTotalPhaseConductorCurrentL2 = 74 # Total current in L2 (sum of all chargers on the circuit) Sent in by master only [Double] ['Admin', 'Partner', 'User'] 82 | state_circuitTotalPhaseConductorCurrentL3 = 75 # Total current in L3 (sum of all chargers on the circuit) Sent in by master only [Double] ['Admin', 'Partner', 'User'] 83 | state_numberOfCarsConnected = 76 # Number of cars connected to this circuit [Integer] ['Admin', 'Partner'] 84 | state_numberOfCarsCharging = 77 # Number of cars currently charging [Integer] ['Admin', 'Partner'] 85 | state_numberOfCarsInQueue = ( 86 | 78 # Number of cars currently in queue, waiting to be allocated power [Integer] ['Admin', 'Partner'] 87 | ) 88 | state_numberOfCarsFullyCharged = 79 # Number of cars that appear to be fully charged [Integer] ['Admin', 'Partner'] 89 | state_chargerFirmware = 80 # Embedded software package release id [boot] [Integer] ['Admin', 'Partner', 'User'] 90 | state_ICCID = 81 # SIM integrated circuit card identifier [String] ['Admin', 'Partner'] 91 | state_modemFwId = 82 # Modem firmware version [String] ['Admin', 'Partner'] 92 | state_OTAErrorCode = 83 # OTA error code, see table [event] [Integer] ['Admin', 'Partner'] 93 | state_mobileNetworkOperator = 84 # Current mobile network operator [pollable] [String] ['Admin', 'Partner'] 94 | state_rebootReason = 89 # Reason of reboot. Bitmask of flags. [Integer] ['Admin', 'Partner'] 95 | state_powerPCBVersion = 90 # Power PCB hardware version [Integer] ['Admin', 'Partner'] 96 | state_comPCBVersion = 91 # Communication PCB hardware version [Integer] ['Admin', 'Partner'] 97 | state_reasonForNoCurrent = 96 # Enum describing why a charger with a car connected is not offering current to the car [Integer] ['Admin', 'Partner', 'User'] 98 | state_loadBalancingNumberOfConnectedChargers = 97 # Number of connected chargers in the load balancin. Including the master. Sent from Master only. [Integer] ['Admin', 'Partner'] 99 | state_UDPNumOfConnectedNodes = ( 100 | 98 # Number of chargers connected to master through UDP / WIFI [Integer] ['Admin', 'Partner'] 101 | ) 102 | state_localConnection = 99 # Slaves only. Current connection to master, 0 = None, 1= Radio, 2 = WIFI UDP, 3 = Radio and WIFI UDP [Integer] ['Admin', 'Partner'] 103 | state_pilotMode = 100 # Pilot Mode Letter (A-F) [event] [String] ['Admin', 'Partner'] 104 | state_carConnectedDEPRECATED = 101 # Car connection state [Boolean] ['Admin', 'Partner'] 105 | state_smartCharging = ( 106 | 102 # Smart charging state enabled by capacitive touch button [event] [Boolean] ['Admin', 'Partner', 'User'] 107 | ) 108 | state_cableLocked = 103 # Cable lock state [event] [Boolean] ['Admin', 'Partner', 'User'] 109 | state_cableRating = 104 # Cable rating read [Amperes][event] [Integer] ['Admin', 'Partner', 'User'] 110 | state_pilotHigh = 105 # Pilot signal high [Volt][debug] [Integer] ['Admin', 'Partner'] 111 | state_pilotLow = 106 # Pilot signal low [Volt][debug] [Integer] ['Admin', 'Partner'] 112 | state_backPlateID = 107 # Back Plate RFID of charger [boot] [String] ['Admin', 'Partner'] 113 | state_userIDTokenReversed = 108 # User ID token string from RFID reading [event](NB! Must reverse these strings) [String] ['Admin', 'Partner'] 114 | state_chargerOpMode = ( 115 | 109 # Charger operation mode according to charger mode table [event] [Integer] ['Admin', 'Partner', 'User'] 116 | ) 117 | state_outputPhase = 110 # Active output phase(s) to EV according to output phase type table. [event] [Integer] ['Admin', 'Partner', 'User'] 118 | state_dynamicCircuitCurrentP1 = 111 # Dynamically set circuit maximum current for phase 1 [Amperes][event] [Double] ['Admin', 'Partner', 'User'] 119 | state_dynamicCircuitCurrentP2 = 112 # Dynamically set circuit maximum current for phase 2 [Amperes][event] [Double] ['Admin', 'Partner', 'User'] 120 | state_dynamicCircuitCurrentP3 = 113 # Dynamically set circuit maximum current for phase 3 [Amperes][event] [Double] ['Admin', 'Partner', 'User'] 121 | state_outputCurrent = 114 # Available current signaled to car with pilot tone [Double] ['Admin', 'Partner', 'User'] 122 | state_deratedCurrent = 115 # Available current after derating [A] [Double] ['Admin', 'Partner', 'User'] 123 | state_deratingActive = 116 # Available current is limited by the charger due to high temperature [event] [Boolean] ['Admin', 'Partner', 'User'] 124 | state_debugString = 117 # Debug string [String] ['Admin', 'Partner'] 125 | state_errorString = 118 # Descriptive error string [event] [String] ['Admin', 'Partner', 'User'] 126 | state_errorCode = 119 # Error code according to error code table [event] [Integer] ['Admin', 'Partner', 'User'] 127 | state_totalPower = 120 # Total power [kW][telemetry] [Double] ['Admin', 'Partner', 'User'] 128 | state_sessionEnergy = 121 # Session accumulated energy [kWh][telemetry] [Double] ['Admin', 'Partner', 'User'] 129 | state_energyPerHour = 122 # Accumulated energy per hour [kWh][event] [Double] ['Admin', 'Partner', 'User'] 130 | state_legacyEvStatus = ( 131 | 123 # 0 = not legacy ev, 1 = legacy ev detected, 2 = reviving ev [Integer] ['Admin', 'Partner'] 132 | ) 133 | state_lifetimeEnergy = ( 134 | 124 # Accumulated energy in the lifetime of the charger [kWh] [Double] ['Admin', 'Partner', 'User'] 135 | ) 136 | state_lifetimeRelaySwitches = 125 # Total number of relay switches in the lifetime of the charger (irrespective of the number of phases used) [Integer] ['Admin', 'Partner'] 137 | state_lifetimeHours = 126 # Total number of hours in operation [Integer] ['Admin', 'Partner'] 138 | config_dynamicCurrentOfflineFallbackDEPRICATED = ( 139 | 127 # Maximum circuit current when offline [event] [Integer] ['Admin', 'Partner'] 140 | ) 141 | state_userIDToken = 128 # User ID token string from RFID reading [event] [String] ['Admin', 'Partner'] 142 | state_ChargingSession = 129 # Charging sessions [json][event] [String] ['Admin', 'Partner'] 143 | state_cellRSSI = 130 # Cellular signal strength [dBm][telemetry] [Integer] ['Admin', 'Partner', 'User'] 144 | state_CellRAT = ( 145 | 131 # Cellular radio access technology according to RAT table [event] [Integer] ['Admin', 'Partner'] 146 | ) 147 | state_wiFiRSSI = 132 # WiFi signal strength [dBm][telemetry] [Integer] ['Admin', 'Partner', 'User'] 148 | config_cellAddress = 133 # IP address assigned by cellular network [debug] [String] ['Admin', 'Partner'] 149 | config_wiFiAddress = 134 # IP address assigned by WiFi network [debug] [String] ['Admin', 'Partner'] 150 | config_wiFiType = 135 # WiFi network type letters (G, N, AC, etc) [debug] [String] ['Admin', 'Partner'] 151 | state_localRSSI = 136 # Local radio signal strength [dBm][telemetry] [Integer] ['Admin', 'Partner', 'User'] 152 | state_masterBackPlateID = 137 # Back Plate RFID of master [event] [String] ['Admin', 'Partner'] 153 | state_localTxPower = 138 # Local radio transmission power [dBm][telemetry] [Integer] ['Admin', 'Partner'] 154 | state_localState = 139 # Local radio state [event] [String] ['Admin', 'Partner'] 155 | state_foundWiFi = 140 # List of found WiFi SSID and RSSI values [event] [String] ['Admin', 'Partner', 'User'] 156 | state_chargerRAT = ( 157 | 141 # Radio access technology in use: 0 = cellular, 1 = wifi [Integer] ['Admin', 'Partner', 'User'] 158 | ) 159 | state_cellularInterfaceErrorCount = 142 # The number of times since boot the system has reported an error on this interface [poll] [Integer] ['Admin', 'Partner'] 160 | state_cellularInterfaceResetCount = 143 # The number of times since boot the interface was reset due to high error count [poll] [Integer] ['Admin', 'Partner'] 161 | state_wifiInterfaceErrorCount = 144 # The number of times since boot the system has reported an error on this interface [poll] [Integer] ['Admin', 'Partner'] 162 | state_wifiInterfaceResetCount = 145 # The number of times since boot the interface was reset due to high error count [poll] [Integer] ['Admin', 'Partner'] 163 | config_localNodeType = ( 164 | 146 # 0-Unconfigured, 1-Master, 2-Extender, 3-End device [Integer] ['Admin', 'Partner', 'User'] 165 | ) 166 | config_localRadioChannel = 147 # Channel nr 0 - 11 [Integer] ['Admin', 'Partner', 'User'] 167 | config_localShortAddress = 148 # Address of charger on local radio network [Integer] ['Admin', 'Partner', 'User'] 168 | config_localParentAddrOrNumOfNodes = ( 169 | 149 # If master-Number of slaves connected, If slave- Address parent [Integer] ['Admin', 'Partner', 'User'] 170 | ) 171 | state_tempMax = ( 172 | 150 # Maximum temperature for all sensors [Celsius][telemetry] [Integer] ['Admin', 'Partner', 'User'] 173 | ) 174 | state_tempAmbientPowerBoard = 151 # Temperature measured by ambient sensor on bottom of power board [Celsius][event] [Integer] ['Admin', 'Partner'] 175 | state_tempInputT2 = 152 # Temperature at input T2 [Celsius][event] [Integer] ['Admin', 'Partner'] 176 | state_tempInputT3 = 153 # Temperature at input T3 [Celsius][event] [Integer] ['Admin', 'Partner'] 177 | state_tempInputT4 = 154 # Temperature at input T4 [Celsius][event] [Integer] ['Admin', 'Partner'] 178 | state_tempInputT5 = 155 # Temperature at input T5 [Celsius][event] [Integer] ['Admin', 'Partner'] 179 | state_tempOutputN = ( 180 | 160 # Temperature at type 2 connector plug for N [Celsius][event] [Integer] ['Admin', 'Partner'] 181 | ) 182 | state_tempOutputL1 = ( 183 | 161 # Temperature at type 2 connector plug for L1 [Celsius][event] [Integer] ['Admin', 'Partner'] 184 | ) 185 | state_tempOutputL2 = ( 186 | 162 # Temperature at type 2 connector plug for L2 [Celsius][event] [Integer] ['Admin', 'Partner'] 187 | ) 188 | state_tempOutputL3 = ( 189 | 163 # Temperature at type 2 connector plug for L3 [Celsius][event] [Integer] ['Admin', 'Partner'] 190 | ) 191 | state_tempRelayN = 164 # Temperature under N relay on ONE [Celsius] [Integer] ['Admin', 'Partner'] 192 | state_tempRelayL = 165 # Temperature under L relay on ONE [Celsius] [Integer] ['Admin', 'Partner'] 193 | state_tempAmbientPowerBoardTop = ( 194 | 166 # Temperature measured by ambient sensor on top of power board [Celsius] [Integer] ['Admin', 'Partner'] 195 | ) 196 | state_tempAmbient = 170 # Ambient temperature on COM board [Celsius][event] [Integer] ['Admin', 'Partner'] 197 | state_lightAmbient = 171 # Ambient light from front side [Percent][debug] [Integer] ['Admin', 'Partner'] 198 | state_intRelHumidity = 172 # Internal relative humidity [Percent][event] [Integer] ['Admin', 'Partner'] 199 | state_backPlateLocked = 173 # Back plate confirmed locked [event] [Boolean] ['Admin', 'Partner'] 200 | state_currentMotor = 174 # Motor current draw [debug] [Double] ['Admin', 'Partner'] 201 | state_backPlateHallSensor = 175 # Raw sensor value [mV] [Integer] ['Admin', 'Partner'] 202 | state_inCurrentT2 = ( 203 | 182 # Calculated current RMS for input T2 [Amperes][telemetry] [Double] ['Admin', 'Partner', 'User'] 204 | ) 205 | state_inCurrentT3 = 183 # Current RMS for input T3 [Amperes][telemetry] [Double] ['Admin', 'Partner', 'User'] 206 | state_inCurrentT4 = 184 # Current RMS for input T4 [Amperes][telemetry] [Double] ['Admin', 'Partner', 'User'] 207 | state_inCurrentT5 = 185 # Current RMS for input T5 [Amperes][telemetry] [Double] ['Admin', 'Partner', 'User'] 208 | state_inVoltageT1T2 = ( 209 | 190 # Input voltage RMS between T1 and T2 [Volt][telemetry] [Double] ['Admin', 'Partner', 'User'] 210 | ) 211 | state_inVoltageT1T3 = ( 212 | 191 # Input voltage RMS between T1 and T3 [Volt][telemetry] [Double] ['Admin', 'Partner', 'User'] 213 | ) 214 | state_inVoltageT1T4 = ( 215 | 192 # Input voltage RMS between T1 and T4 [Volt][telemetry] [Double] ['Admin', 'Partner', 'User'] 216 | ) 217 | state_inVoltageT1T5 = ( 218 | 193 # Input voltage RMS between T1 and T5 [Volt][telemetry] [Double] ['Admin', 'Partner', 'User'] 219 | ) 220 | state_inVoltageT2T3 = ( 221 | 194 # Input voltage RMS between T2 and T3 [Volt][telemetry] [Double] ['Admin', 'Partner', 'User'] 222 | ) 223 | state_inVoltageT2T4 = ( 224 | 195 # Input voltage RMS between T2 and T4 [Volt][telemetry] [Double] ['Admin', 'Partner', 'User'] 225 | ) 226 | state_inVoltageT2T5 = ( 227 | 196 # Input voltage RMS between T2 and T5 [Volt][telemetry] [Double] ['Admin', 'Partner', 'User'] 228 | ) 229 | state_inVoltageT3T4 = ( 230 | 197 # Input voltage RMS between T3 and T4 [Volt][telemetry] [Double] ['Admin', 'Partner', 'User'] 231 | ) 232 | state_inVoltageT3T5 = ( 233 | 198 # Input voltage RMS between T3 and T5 [Volt][telemetry] [Double] ['Admin', 'Partner', 'User'] 234 | ) 235 | state_inVoltageT4T5 = ( 236 | 199 # Input voltage RMS between T4 and T5 [Volt][telemetry] [Double] ['Admin', 'Partner', 'User'] 237 | ) 238 | state_nominalVoltage = 200 # Nominal voltage setting for Easee One [Integer] ['Admin', 'Partner', 'User'] 239 | state_outVoltPin1to2 = ( 240 | 202 # Output voltage RMS between type 2 pin 1 and 2 [Volt][telemetry] [Double] ['Admin', 'Partner'] 241 | ) 242 | state_outVoltPin1to3 = ( 243 | 203 # Output voltage RMS between type 2 pin 1 and 3 [Volt][telemetry] [Double] ['Admin', 'Partner'] 244 | ) 245 | state_outVoltPin1to4 = ( 246 | 204 # Output voltage RMS between type 2 pin 1 and 4 [Volt][telemetry] [Double] ['Admin', 'Partner'] 247 | ) 248 | state_outVoltPin1to5 = ( 249 | 205 # Output voltage RMS between type 2 pin 1 and 5 [Volt][telemetry] [Double] ['Admin', 'Partner'] 250 | ) 251 | state_outVoltPin2_3 = 206 # Output voltage RMS between type 2 pin 2 and 3 [Volt] [Double] ['Admin', 'Partner'] 252 | state_voltLevel33 = 210 # 3.3 Volt Level [Volt][telemetry] [Integer] ['Admin', 'Partner'] 253 | state_voltLevel5 = 211 # 5 Volt Level [Volt][telemetry] [Integer] ['Admin', 'Partner'] 254 | state_voltLevel12 = 212 # 12 Volt Level [Volt][telemetry] [Integer] ['Admin', 'Partner'] 255 | state_fatalErrorCode = ( 256 | 219 # Fatal error code according to error code table [event] [Integer] ['Admin', 'Partner', 'User'] 257 | ) 258 | state_LTERSRP = 220 # Reference Signal Received Power (LTE) [-144 .. -44 dBm] [Integer] ['Admin', 'Partner'] 259 | state_LTESINR = 221 # Signal to Interference plus Noise Ratio (LTE) [-20 .. +30 dB] [Integer] ['Admin', 'Partner'] 260 | state_LTERSRQ = 222 # Reference Signal Received Quality (LTE) [-19 .. -3 dB] [Integer] ['Admin', 'Partner'] 261 | state_chargingSessionStart = 223 # Charging session started [event] [String] ['Admin', 'Partner'] 262 | state_eqAvailableCurrentP1 = ( 263 | 230 # Available current for charging on P1 according to Equalizer [Double] ['Admin', 'Partner', 'User'] 264 | ) 265 | state_eqAvailableCurrentP2 = ( 266 | 231 # Available current for charging on P2 according to Equalizer [Double] ['Admin', 'Partner', 'User'] 267 | ) 268 | state_eqAvailableCurrentP3 = ( 269 | 232 # Available current for charging on P3 according to Equalizer [Double] ['Admin', 'Partner', 'User'] 270 | ) 271 | state_diagnosticsString = 240 # Various diagnostic information for testing. Must be enabled by setting DiagnosticsMode == 256 [String] ['Admin', 'Partner'] 272 | config_wiFiMACAddress = 241 # Device WiFi MAC address 273 | state_connectedToCloud = 250 # Device is connected to AWS [Boolean] ['Admin', 'Partner', 'User'] 274 | state_cloudDisconnectReason = 251 # AWS DisconnectReason [String] ['Admin', 'Partner', 'User'] 275 | 276 | 277 | # Data IDs for Equalizer from signalr stream 278 | class EqualizerStreamData(Enum): 279 | state_selfTestResult = 1 # PASSED or error codes [String] ['Admin', 'Partner'] 280 | state_selfTestDetails = 2 # JSON with details from self-test [String] ['Admin', 'Partner'] 281 | state_easeeLinkCommandResponse = ( 282 | 13 # Response on a EaseeLink command sent to another devic [Integer] ['Admin', 'Partner'] 283 | ) 284 | state_easeeLinkDataReceived = 14 # Data received on EaseeLink from another device [String] ['Admin', 'Partner'] 285 | state_siteIDNumeric = 19 # Site ID numeric value [event] [Integer] ['Admin', 'Partner'] 286 | config_siteStructure = 20 # Site Structure [boot] [String] ['Admin', 'Partner', 'User'] 287 | state_softwareRelease = 21 # Embedded software package release id [boot] [Integer] ['Admin', 'Partner', 'User'] 288 | state_deviceMode = 23 # Current device mode [Integer] ['Admin', 'Partner', 'User'] 289 | config_meterType = 25 # Meter type [String] ['Admin', 'Partner', 'User'] 290 | config_meterID = 26 # Meter identification [String] ['Admin', 'Partner', 'User'] 291 | state_OBISListIdentifier = 27 # OBIS List version identifier [String] ['Admin', 'Partner'] 292 | config_gridType = 29 # 0=Unknown, 1=TN, 2=IT, [Integer] ['Admin', 'Partner', 'User'] 293 | config_numPhases = 30 # [Integer] ['Admin', 'Partner', 'User'] 294 | state_currentL1 = 31 # Current in Amps [Double] ['Admin', 'Partner', 'User'] 295 | state_currentL2 = 32 # Current in Amps [Double] ['Admin', 'Partner', 'User'] 296 | state_currentL3 = 33 # Current in Amps [Double] ['Admin', 'Partner', 'User'] 297 | state_voltageNL1 = 34 # Voltage in Volts [Double] ['Admin', 'Partner', 'User'] 298 | state_voltageNL2 = 35 # Voltage in Volts [Double] ['Admin', 'Partner', 'User'] 299 | state_voltageNL3 = 36 # Voltage in Volts [Double] ['Admin', 'Partner', 'User'] 300 | state_voltageL1L2 = 37 # Voltage in Volts [Double] ['Admin', 'Partner', 'User'] 301 | state_voltageL1L3 = 38 # Voltage in Volts [Double] ['Admin', 'Partner', 'User'] 302 | state_voltageL2L3 = 39 # Voltage in Volts [Double] ['Admin', 'Partner', 'User'] 303 | state_activePowerImport = 40 # Active Power Import in kW [Double] ['Admin', 'Partner', 'User'] 304 | state_activePowerExport = 41 # Active Power Export in kW [Double] ['Admin', 'Partner', 'User'] 305 | state_reactivePowerImport = 42 # Reactive Power Import in kVAR [Double] ['Admin', 'Partner', 'User'] 306 | state_reactivePowerExport = 43 # Reactive Power Export in kVAR [Double] ['Admin', 'Partner', 'User'] 307 | state_maxPowerImport = 44 # Maximum power import[event] [Double] ['Admin', 'Partner', 'User'] 308 | state_cumulativeActivePowerImport = ( 309 | 45 # Cumulative Active Power Import in kWh [Double] ['Admin', 'Partner', 'User'] 310 | ) 311 | state_cumulativeActivePowerExport = ( 312 | 46 # Cumulative Active Power Export in kWh [Double] ['Admin', 'Partner', 'User'] 313 | ) 314 | state_cumulativeReactivePowerImport = ( 315 | 47 # Cumulative Reactive Power Import in kVARh [Double] ['Admin', 'Partner', 'User'] 316 | ) 317 | state_cumulativeReactivePowerExport = ( 318 | 48 # Cumulative Reactive Power Export in kVARh [Double] ['Admin', 'Partner', 'User'] 319 | ) 320 | state_clockAndDateMeter = 49 # Clock and Date from Meter [String] ['Admin', 'Partner', 'User'] 321 | state_rcpi = 50 # Received Channel Power Indicator (dBm) [Double] ['Admin', 'Partner', 'User'] 322 | config_ssid = 51 # WIFI SSID [String] ['Admin', 'Partner', 'User'] 323 | config_masterBackPlateID = 55 # Back Plate RFID of master charger [event] [String] ['Admin', 'Partner', 'User'] 324 | config_equalizerID = 56 # Back Plate RFID of equalizer [boot] [String] ['Admin', 'Partner', 'User'] 325 | config_childReport = 57 # Child configuration in Equalizer [String] ['Admin', 'Partner', 'User'] 326 | state_connectivityReport = 58 # Child connectivity [String] ['Admin', 'Partner'] 327 | state_exceptionData = 60 # Exception Debug Information [boot] [String] ['Admin', 'Partner'] 328 | state_bootReason = 61 # (Re)boot reason [boot] [String] ['Admin', 'Partner'] 329 | state_highCurrentTransitions = ( 330 | 64 # Number of transitions to high current mode (Debug) [Integer] ['Admin', 'Partner'] 331 | ) 332 | state_vCap = 65 # Capacitor Voltage in Volts [Double] ['Admin', 'Partner'] 333 | state_vBusMin = 66 # Minimum Bus Voltage in Volts [Double] ['Admin', 'Partner'] 334 | state_vbusMax = 67 # Maximum Bus Voltage in Volts [Double] ['Admin', 'Partner'] 335 | state_internalTemperature = 68 # Internal temperature in Celsius [Double] ['Admin', 'Partner'] 336 | state_meterDataSnapshot = 69 # Meter data snapshot [String] ['Admin', 'Partner'] 337 | state_localRSSI = 70 # Local radio signal strength [dBm][telemetry] [Integer] ['Admin', 'Partner', 'User'] 338 | state_localTxPower = 71 # Local radio transmission power [dBm][telemetry] [Integer] ['Admin', 'Partner'] 339 | state_localRadioChannel = 72 # Local radio channel nr 0 - 11 [telemetry] [Integer] ['Admin', 'Partner'] 340 | state_localShortAddress = ( 341 | 73 # Address of equalizer on local radio network [telemetry] [Integer] ['Admin', 'Partner'] 342 | ) 343 | state_localNodeType = 74 # 0-Unconfigured, 1 - Coordinator, 2 - Range extender, 3 - End device, 4- Sleepy end device [telemetry] [Integer] ['Admin', 'Partner'] 344 | state_localParentAddress = 75 # Address of parent on local radio network. If 0 - master, else extender [telemetry] [Integer] ['Admin', 'Partner'] 345 | config_circuitPhaseMapping = 80 # Mapping between EQ phases and charger phases [String] ['Admin', 'Partner'] 346 | state_phaseMappingReport = 81 # Charger vs Meter phase correlation report [String] ['Admin', 'Partner'] 347 | state_phaseLearningStatus = 82 # Status of the phaselearning/loadbalancing [String] ['Admin', 'Partner'] 348 | config_modbusConfiguration = 85 # Complete Modbus Configuration [String] ['Admin', 'Partner', 'User'] 349 | state_loadbalanceThrottle = 86 # Throttle level [percent] [Integer] ['Admin', 'Partner'] 350 | state_availableCurrentL1 = 87 # Available Current for Balancing in Amps [Double] ['Admin', 'Partner', 'User'] 351 | state_availableCurrentL2 = 88 # Available Current for Balancing in Amps [Double] ['Admin', 'Partner', 'User'] 352 | state_availableCurrentL3 = 89 # Available Current for Balancing in Amps [Double] ['Admin', 'Partner', 'User'] 353 | state_meterErrors = 90 # Meter Errors [Integer] ['Admin', 'Partner'] 354 | state_APMacAddress = 91 # Mac Address of the Wifi access point [String] ['Admin', 'Partner'] 355 | state_wifiReconnects = 92 # Number of sucessful reconnects to AP [Integer] ['Admin', 'Partner'] 356 | state_ledMode = 100 # Current LED pattern [Integer] ['Admin', 'Partner', 'User'] 357 | state_equalizedChargeCurrentL1 = ( 358 | 105 # Charge current controlled by Equalizer in Amps [Double] ['Admin', 'Partner', 'User'] 359 | ) 360 | state_equalizedChargeCurrentL2 = ( 361 | 106 # Charge current controlled by Equalizer in Amps [Double] ['Admin', 'Partner', 'User'] 362 | ) 363 | state_equalizedChargeCurrentL3 = ( 364 | 107 # Charge current controlled by Equalizer in Amps [Double] ['Admin', 'Partner', 'User'] 365 | ) 366 | config_currentTransformerConfig = 110 # Current Transformer Configuration [String] ['Admin', 'Partner', 'User'] 367 | state_meterEncryptionStatus = 111 # Meter Encryption Status [String] ['Admin', 'Partner', 'User'] 368 | config_surplusCharging = 115 # Surplus charging configuration [String] ['Admin', 'Partner', 'User'] 369 | state_connectedAmps = 120 # Equalizer AMPs report ['Admin', 'Partner', 'User'] 370 | state_connectedToCloud = 250 # Device is connected to AWS [Boolean] ['Admin', 'Partner', 'User'] 371 | state_cloudDisconnectReason = 251 # AWS DisconnectReason [String] ['Admin', 'Partner', 'User'] 372 | 373 | 374 | # Data IDs for Equalizer from signalr stream 375 | class DatatypesStreamData(Enum): 376 | Binary = 1 377 | Boolean = 2 378 | Double = 3 379 | Integer = 4 380 | Position = 5 381 | String = 6 382 | Statistics = 7 383 | -------------------------------------------------------------------------------- /pyeasee/charger.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | import logging 3 | from typing import Any, Dict, Union 4 | 5 | from .exceptions import NotFoundException, ServerFailureException 6 | from .throttler import Throttler 7 | from .utils import BaseDict 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | STATUS = { 13 | 0: "OFFLINE", 14 | 1: "DISCONNECTED", 15 | 2: "AWAITING_START", 16 | 3: "CHARGING", 17 | 4: "COMPLETED", 18 | 5: "ERROR", 19 | 6: "READY_TO_CHARGE", 20 | 7: "AWAITING_AUTHORIZATION", 21 | 8: "DE_AUTHORIZING", 22 | 100: "START_CHARGING", 23 | 101: "STOP_CHARGING", 24 | 102: "OFFLINE", 25 | 103: "AWAITING_LOAD_BALANCING", 26 | 104: "AWAITING_AUTHORIZATION", 27 | 105: "AWAITING_SMART_START", 28 | 106: "AWAITING_SCHEDULED_START", 29 | 107: "AUTHENTICATING", 30 | 108: "PAUSED_DUE_TO_EQUALIZER", 31 | 109: "SEARCHING_FOR_MASTER", 32 | 157: "ERRATIC_EV", 33 | 158: "ERROR_TEMPERATURE_TOO_HIGH", 34 | 159: "ERROR_DEAD_POWERBOARD", 35 | 160: "ERROR_OVERCURRENT", 36 | 161: "ERROR_PEN_FAULT", 37 | } 38 | 39 | NODE_TYPE = {1: "Master", 2: "Extender"} 40 | 41 | PHASE_MODE = {1: "Locked to single phase", 2: "Auto", 3: "Locked to three phase"} 42 | 43 | REASON_FOR_NO_CURRENT = { 44 | # Work-in-progress, must be taken with a pinch of salt, as per now just reverse engineering of observations until API properly documented 45 | None: "No reason", 46 | 0: "No reason, charging or ready to charge", 47 | 1: "Max circuit limit too low", 48 | 2: "Max dynamic circuit limit too low", 49 | 3: "Max dynamic offline limit too low", 50 | 4: "Circuit fuse too low", 51 | 5: "Waiting in queue", 52 | 6: "Waiting in fully", 53 | 7: "Illegal grid type", 54 | 8: "No current request recieved", 55 | 9: "Not connected to master", 56 | 10: "EQ current too low", 57 | 11: "Phase not connected", 58 | 25: "Limited by circuit fuse", 59 | 26: "Limited by circuit max limit", 60 | 27: "Limited by circuit dynamic limit", 61 | 28: "Limited by equalizer", 62 | 29: "Limited by load balancing", 63 | 30: "Limited by offline settings", 64 | 50: "Secondary unit not requesting current or no car connected", 65 | 51: "Max charger limit too low", 66 | 52: "Max dynamic charger limit too low", 67 | 53: "Charger disabled", 68 | 54: "Waiting for schedule/auth", 69 | 55: "Pending auth", 70 | 56: "Charger in error state", 71 | 57: "EV erratic", 72 | 75: "Limited by cable rating", 73 | 76: "Limited by schedule", 74 | 77: "Limited by charger max limit", 75 | 78: "Limited by charger dynamic limit", 76 | 79: "EV is not charging", 77 | 80: "Limited by local adjustment", 78 | 81: "Limited by EV", 79 | 100: "Undefined", 80 | } 81 | 82 | 83 | class ChargerState(BaseDict): 84 | """Charger state with integer enum values converted to human readable string values""" 85 | 86 | def __init__(self, state: Dict[str, Any], raw=False): 87 | if not raw: 88 | data = { 89 | **state, 90 | "chargerOpMode": STATUS[state["chargerOpMode"]], 91 | "reasonForNoCurrent": f"({state['reasonForNoCurrent']}) {REASON_FOR_NO_CURRENT.get(state['reasonForNoCurrent'], 'Unknown')}", 92 | } 93 | else: 94 | data = { 95 | **state, 96 | "reasonForNoCurrent": "none" if state["reasonForNoCurrent"] is None else state["reasonForNoCurrent"], 97 | } 98 | super().__init__(data) 99 | 100 | 101 | class ChargerConfig(BaseDict): 102 | """Charger config with integer enum values converted to human readable string values""" 103 | 104 | def __init__(self, config: Dict[str, Any], raw=False): 105 | if not raw: 106 | data = { 107 | **config, 108 | "localNodeType": NODE_TYPE[config["localNodeType"]], 109 | "phaseMode": PHASE_MODE[config["phaseMode"]], 110 | } 111 | else: 112 | data = {**config} 113 | super().__init__(data) 114 | 115 | 116 | class ChargerSchedule(BaseDict): 117 | """Charger charging schedule/plan""" 118 | 119 | def __init__(self, schedule: Dict[str, Any]): 120 | data = { 121 | "id": schedule.get("id"), 122 | "chargeStartTime": schedule.get("chargeStartTime"), 123 | "chargeStopTime": schedule.get("chargeStopTime"), 124 | "repeat": schedule.get("repeat"), 125 | "isEnabled": schedule.get("isEnabled"), 126 | "chargingCurrentLimit": schedule.get("chargingCurrentLimit"), 127 | } 128 | super().__init__(data) 129 | 130 | 131 | class ChargerWeeklySchedule(BaseDict): 132 | """Charger charging schedule/plan""" 133 | 134 | def __init__(self, schedule: Dict[str, Any]): 135 | days = schedule.get("days") 136 | data = { 137 | "isEnabled": schedule.get("isEnabled"), 138 | "MondayStartTime": "-", 139 | "MondayStopTime": "-", 140 | "TuesdayStartTime": "-", 141 | "TuesdayStopTime": "-", 142 | "WednesdayStartTime": "-", 143 | "WednesdayStopTime": "-", 144 | "ThursdayStartTime": "-", 145 | "ThursdayStopTime": "-", 146 | "FridayStartTime": "-", 147 | "FridayStopTime": "-", 148 | "SaturdayStartTime": "-", 149 | "SaturdayStopTime": "-", 150 | "SundayStartTime": "-", 151 | "SundayStopTime": "-", 152 | "days": days, 153 | } 154 | if data["isEnabled"]: 155 | tzinfo = datetime.utcnow().astimezone().tzinfo 156 | for day in days: 157 | ranges = day["ranges"] 158 | for times in ranges: 159 | limit = times["chargingCurrentLimit"] 160 | try: 161 | start = ( 162 | datetime.strptime(times["startTime"], "%H:%MZ") 163 | .replace(tzinfo=timezone.utc) 164 | .astimezone(tzinfo) 165 | .strftime("%H:%M") 166 | ) 167 | stop = ( 168 | datetime.strptime(times["stopTime"], "%H:%MZ") 169 | .replace(tzinfo=timezone.utc) 170 | .astimezone(tzinfo) 171 | .strftime("%H:%M") 172 | ) 173 | except ValueError: 174 | start = ( 175 | datetime.strptime(times["startTime"], "%H:%M") 176 | .replace(tzinfo=timezone.utc) 177 | .astimezone(tzinfo) 178 | .strftime("%H:%M") 179 | ) 180 | stop = ( 181 | datetime.strptime(times["stopTime"], "%H:%M") 182 | .replace(tzinfo=timezone.utc) 183 | .astimezone(tzinfo) 184 | .strftime("%H:%M") 185 | ) 186 | 187 | if day["dayOfWeek"] == 0: 188 | data["MondayStartTime"] = start 189 | data["MondayStopTime"] = stop 190 | data["MondayLimit"] = limit 191 | elif day["dayOfWeek"] == 1: 192 | data["TuesdayStartTime"] = start 193 | data["TuesdayStopTime"] = stop 194 | data["TuesdayLimit"] = limit 195 | elif day["dayOfWeek"] == 2: 196 | data["WednesdayStartTime"] = start 197 | data["WednesdayStopTime"] = stop 198 | data["WednesdayLimit"] = limit 199 | elif day["dayOfWeek"] == 3: 200 | data["ThursdayStartTime"] = start 201 | data["ThursdayStopTime"] = stop 202 | data["ThursdayLimit"] = limit 203 | elif day["dayOfWeek"] == 4: 204 | data["FridayStartTime"] = start 205 | data["FridayStopTime"] = stop 206 | data["FridayLimit"] = limit 207 | elif day["dayOfWeek"] == 5: 208 | data["SaturdayStartTime"] = start 209 | data["SaturdayStopTime"] = stop 210 | data["SaturdayLimit"] = limit 211 | elif day["dayOfWeek"] == 6: 212 | data["SundayStartTime"] = start 213 | data["SundayStopTime"] = stop 214 | data["SundayLimit"] = limit 215 | 216 | super().__init__(data) 217 | 218 | 219 | class ChargerSession(BaseDict): 220 | """Charger charging session""" 221 | 222 | def __init__(self, session: Dict[str, Any]): 223 | data = { 224 | "carConnected": session.get("carConnected"), 225 | "carDisconnected": session.get("carDisconnected"), 226 | "kiloWattHours": float(session.get("kiloWattHours")), 227 | } 228 | super().__init__(data) 229 | 230 | 231 | class Charger(BaseDict): 232 | def __init__(self, entries: Dict[str, Any], easee: Any, site: Any = None, circuit: Any = None): 233 | super().__init__(entries) 234 | self.id: str = entries["id"] 235 | self.name: str = entries["name"] 236 | self.product_code: int = entries["productCode"] 237 | self.level_of_access: int = entries["levelOfAccess"] 238 | self.user_role: int = entries.get("userRole", -1) 239 | self.site = site 240 | self.circuit = circuit 241 | self.easee = easee 242 | self._consumption_between_dates_throttler = Throttler( 243 | rate_limit=10, period=3600, name="consumption between dates" 244 | ) 245 | self._sessions_between_dates_throttler = Throttler(rate_limit=10, period=3600, name="sessions between dates") 246 | 247 | async def get_observations(self, *args): 248 | """Gets observation IDs""" 249 | observation_ids = ",".join(str(s) for s in args) 250 | try: 251 | return await (await self.easee.get(f"/state/{self.id}/observations?ids={observation_ids}")).json() 252 | except (ServerFailureException): 253 | return None 254 | 255 | async def get_consumption_between_dates(self, from_date: datetime, to_date): 256 | """Gets consumption between two dates""" 257 | try: 258 | async with self._consumption_between_dates_throttler: 259 | value = await ( 260 | await self.easee.get( 261 | f"/api/sessions/charger/{self.id}/total/{from_date.isoformat()}/{to_date.isoformat()}" 262 | ) 263 | ).text() 264 | return float(value) 265 | except (ServerFailureException): 266 | return None 267 | 268 | async def get_hourly_consumption_between_dates(self, from_date: datetime, to_date: datetime): 269 | """Gets hourly consumption between two dates 270 | Note when calling: Seems to be capped at requesting max one month at a time 271 | """ 272 | try: 273 | value = await ( 274 | await self.easee.get( 275 | f"/api/chargers/{self.id}/usage/hourly/{from_date.isoformat()}/{to_date.isoformat()}" 276 | ) 277 | ).json() 278 | return value 279 | except (ServerFailureException): 280 | return None 281 | 282 | async def get_sessions_between_dates(self, from_date: datetime, to_date): 283 | """Gets charging sessions between two dates""" 284 | try: 285 | async with self._sessions_between_dates_throttler: 286 | sessions = await ( 287 | await self.easee.get( 288 | f"/api/sessions/charger/{self.id}/sessions/{from_date.isoformat()}/{to_date.isoformat()}" 289 | ) 290 | ).json() 291 | sessions = [ChargerSession(session) for session in sessions] 292 | sessions.sort(key=lambda x: x["carConnected"], reverse=True) 293 | return sessions 294 | except (ServerFailureException): 295 | return None 296 | 297 | async def get_config(self, from_cache=False, raw=False) -> ChargerConfig: 298 | """get config for charger""" 299 | try: 300 | config = await (await self.easee.get(f"/api/chargers/{self.id}/config")).json() 301 | return ChargerConfig(config, raw) 302 | except (ServerFailureException): 303 | return None 304 | 305 | async def get_state(self, raw=False) -> ChargerState: 306 | """get state for charger""" 307 | try: 308 | state = await (await self.easee.get(f"/api/chargers/{self.id}/state")).json() 309 | return ChargerState(state, raw) 310 | except (ServerFailureException): 311 | return None 312 | 313 | async def empty_config(self, raw=False) -> ChargerConfig: 314 | """Create an empty config data structure""" 315 | config = {} 316 | return ChargerConfig(config, raw) 317 | 318 | async def empty_state(self, raw=False) -> ChargerConfig: 319 | """Create an empty config data structure""" 320 | state = { 321 | "chargerOpMode": 0, 322 | "reasonForNoCurrent": 0, 323 | } 324 | return ChargerState(state, raw) 325 | 326 | async def start(self): 327 | """Start charging session""" 328 | try: 329 | return await self.easee.post(f"/api/chargers/{self.id}/commands/start_charging") 330 | except (ServerFailureException): 331 | return None 332 | 333 | async def pause(self): 334 | """Pause charging session""" 335 | try: 336 | return await self.easee.post(f"/api/chargers/{self.id}/commands/pause_charging") 337 | except (ServerFailureException): 338 | return None 339 | 340 | async def resume(self): 341 | """Resume charging session""" 342 | try: 343 | return await self.easee.post(f"/api/chargers/{self.id}/commands/resume_charging") 344 | except (ServerFailureException): 345 | return None 346 | 347 | async def stop(self): 348 | """Stop charging session""" 349 | try: 350 | return await self.easee.post(f"/api/chargers/{self.id}/commands/stop_charging") 351 | except (ServerFailureException): 352 | return None 353 | 354 | async def toggle(self): 355 | """Toggle charging session start/stop/pause/resume""" 356 | try: 357 | return await self.easee.post(f"/api/chargers/{self.id}/commands/toggle_charging") 358 | except (ServerFailureException): 359 | return None 360 | 361 | async def get_basic_charge_plan(self) -> ChargerSchedule: 362 | """Get and return charger basic charge plan setting from cloud""" 363 | try: 364 | plan = await self.easee.get(f"/api/chargers/{self.id}/basic_charge_plan") 365 | plan = await plan.json() 366 | return ChargerSchedule(plan) 367 | except (NotFoundException): 368 | _LOGGER.debug("No scheduled charge plan") 369 | return None 370 | except (ServerFailureException): 371 | return None 372 | 373 | # TODO: document types 374 | async def set_basic_charge_plan( 375 | self, id, chargeStartTime, chargeStopTime=None, repeat=True, isEnabled=True, limit=32 376 | ): 377 | """Set and post charger basic charge plan setting to cloud""" 378 | json = { 379 | "id": id, 380 | "chargeStartTime": str(chargeStartTime), 381 | "repeat": repeat, 382 | "isEnabled": isEnabled, 383 | "chargingCurrentLimit": limit, 384 | } 385 | if chargeStopTime is not None: 386 | json["chargeStopTime"] = str(chargeStopTime) 387 | 388 | try: 389 | return await self.easee.post(f"/api/chargers/{self.id}/basic_charge_plan", json=json) 390 | except (ServerFailureException): 391 | return None 392 | 393 | async def disable_basic_charge_plan(self): 394 | await self.enable_basic_charge_plan(False) 395 | 396 | async def enable_basic_charge_plan(self, enable=True): 397 | """Enabled or disable basic charge plan without changing other settings.""" 398 | 399 | try: 400 | plan = await self.easee.get(f"/api/chargers/{self.id}/basic_charge_plan") 401 | plan = await plan.json() 402 | except (NotFoundException): 403 | _LOGGER.debug("No scheduled charge plan") 404 | plan = None 405 | except (ServerFailureException): 406 | plan = None 407 | 408 | if plan is not None: 409 | plan["isEnabled"] = enable 410 | json = plan 411 | 412 | try: 413 | return await self.easee.post(f"/api/chargers/{self.id}/basic_charge_plan", json=json) 414 | except (ServerFailureException): 415 | return None 416 | 417 | async def get_weekly_charge_plan(self) -> ChargerWeeklySchedule: 418 | """Get and return charger weekly charge plan setting from cloud""" 419 | try: 420 | plan = await self.easee.get(f"/api/chargers/{self.id}/weekly_charge_plan") 421 | plan = await plan.json() 422 | _LOGGER.debug(plan) 423 | return ChargerWeeklySchedule(plan) 424 | except (NotFoundException): 425 | _LOGGER.debug("No scheduled charge plan") 426 | return None 427 | except (ServerFailureException): 428 | return None 429 | 430 | # TODO: document types 431 | async def set_weekly_charge_plan(self, day, chargeStartTime, chargeStopTime, enabled=True, limit=32): 432 | """Set and post charger weekly charge plan setting to cloud""" 433 | 434 | try: 435 | plan = await self.easee.get(f"/api/chargers/{self.id}/weekly_charge_plan") 436 | plan = await plan.json() 437 | _LOGGER.debug(plan) 438 | except (NotFoundException): 439 | _LOGGER.debug("No scheduled charge plan") 440 | plan = None 441 | except (ServerFailureException): 442 | return None 443 | 444 | if plan is None: 445 | json = { 446 | "isEnabled": enabled, 447 | "days": [ 448 | { 449 | "dayOfWeek": day, 450 | "ranges": [ 451 | { 452 | "startTime": str(chargeStartTime), 453 | "stopTime": str(chargeStopTime), 454 | "chargingCurrentLimit": limit, 455 | } 456 | ], 457 | } 458 | ], 459 | } 460 | else: 461 | json = plan 462 | json["isEnabled"] = enabled 463 | days = json["days"] 464 | newdays = [] 465 | for oldday in days: 466 | if oldday["dayOfWeek"] == day: 467 | newday = { 468 | "dayOfWeek": day, 469 | "ranges": [ 470 | { 471 | "startTime": str(chargeStartTime), 472 | "stopTime": str(chargeStopTime), 473 | "chargingCurrentLimit": limit, 474 | } 475 | ], 476 | } 477 | newdays.append(newday) 478 | else: 479 | newdays.append(oldday) 480 | json["days"] = newdays 481 | 482 | try: 483 | return await self.easee.post(f"/api/chargers/{self.id}/weekly_charge_plan", json=json) 484 | except (ServerFailureException): 485 | return None 486 | 487 | async def disable_weekly_charge_plan(self): 488 | await self.enable_weekly_charge_plan(False) 489 | 490 | async def enable_weekly_charge_plan(self, enable=True): 491 | """Enable or disable charger weekly charge plan setting to cloud""" 492 | 493 | try: 494 | plan = await self.easee.get(f"/api/chargers/{self.id}/weekly_charge_plan") 495 | plan = await plan.json() 496 | _LOGGER.debug(plan) 497 | except (NotFoundException): 498 | _LOGGER.debug("No scheduled charge plan") 499 | plan = None 500 | except (ServerFailureException): 501 | return None 502 | 503 | if plan is not None: 504 | json = plan 505 | json["isEnabled"] = enable 506 | 507 | try: 508 | return await self.easee.post(f"/api/chargers/{self.id}/weekly_charge_plan", json=json) 509 | except (ServerFailureException): 510 | return None 511 | 512 | async def enable_charger(self, enable: bool): 513 | """Enable and disable charger in charger settings""" 514 | json = {"enabled": enable} 515 | try: 516 | return await self.easee.post(f"/api/chargers/{self.id}/settings", json=json) 517 | except (ServerFailureException): 518 | return None 519 | 520 | async def enable_idle_current(self, enable: bool): 521 | """Enable and disable idle current in charger settings""" 522 | json = {"enableIdleCurrent": enable} 523 | try: 524 | return await self.easee.post(f"/api/chargers/{self.id}/settings", json=json) 525 | except (ServerFailureException): 526 | return None 527 | 528 | async def limitToSinglePhaseCharging(self, enable: bool): 529 | """Limit to single phase charging in charger settings""" 530 | json = {"limitToSinglePhaseCharging": enable} 531 | try: 532 | return await self.easee.post(f"/api/chargers/{self.id}/settings", json=json) 533 | except (ServerFailureException): 534 | return None 535 | 536 | async def phaseMode(self, mode: int = 2): 537 | """Set charging phase mode, 1 = always 1-phase, 2 = auto, 3 = always 3-phase""" 538 | json = {"phaseMode": mode} 539 | try: 540 | return await self.easee.post(f"/api/chargers/{self.id}/settings", json=json) 541 | except (ServerFailureException): 542 | return None 543 | 544 | async def lockCablePermanently(self, enable: bool): 545 | """Lock and unlock cable permanently in charger settings""" 546 | json = {"state": enable} 547 | try: 548 | return await self.easee.post(f"/api/chargers/{self.id}/commands/lock_state", json=json) 549 | except (ServerFailureException): 550 | return None 551 | 552 | async def smartButtonEnabled(self, enable: bool): 553 | """Enable and disable smart button in charger settings""" 554 | json = {"smartButtonEnabled": enable} 555 | try: 556 | return await self.easee.post(f"/api/chargers/{self.id}/settings", json=json) 557 | except (ServerFailureException): 558 | return None 559 | 560 | async def delete_basic_charge_plan(self): 561 | """Delete charger basic charge plan setting from cloud""" 562 | try: 563 | return await self.easee.delete(f"/api/chargers/{self.id}/basic_charge_plan") 564 | except (ServerFailureException): 565 | return None 566 | 567 | async def delete_weekly_charge_plan(self): 568 | """Delete charger basic charge plan setting from cloud""" 569 | try: 570 | return await self.easee.delete(f"/api/chargers/{self.id}/weekly_charge_plan") 571 | except (ServerFailureException): 572 | return None 573 | 574 | async def override_schedule(self): 575 | """Override scheduled charging and start charging""" 576 | try: 577 | return await self.easee.post(f"/api/chargers/{self.id}/commands/override_schedule") 578 | except (ServerFailureException): 579 | return None 580 | 581 | async def smart_charging(self, enable: bool): 582 | """Set charger smart charging setting""" 583 | json = {"smartCharging": enable} 584 | try: 585 | return await self.easee.post(f"/api/chargers/{self.id}/settings", json=json) 586 | except (ServerFailureException): 587 | return None 588 | 589 | async def reboot(self): 590 | """Reboot charger""" 591 | try: 592 | return await self.easee.post(f"/api/chargers/{self.id}/commands/reboot") 593 | except (ServerFailureException): 594 | return None 595 | 596 | async def update_firmware(self): 597 | """Update charger firmware""" 598 | try: 599 | return await self.easee.post(f"/api/chargers/{self.id}/commands/update_firmware") 600 | except (ServerFailureException): 601 | return None 602 | 603 | async def get_latest_firmware(self): 604 | """Get the latest released firmeware version""" 605 | try: 606 | return await (await self.easee.get(f"/firmware/{self.id}/latest")).json() 607 | except (ServerFailureException): 608 | return None 609 | 610 | async def set_dynamic_charger_circuit_current( 611 | self, currentP1: int, currentP2: int = None, currentP3: int = None, timeToLive: int = 0 612 | ): 613 | """Set dynamic current on circuit level. timeToLive specifies, in minutes, for how long the new dynamic current is valid. timeToLive = 0 means that the new dynamic current is valid until changed the next time. The dynamic current is always reset to default when the charger is restarted.""" 614 | if self.circuit is not None: 615 | return await self.circuit.set_dynamic_current(currentP1, currentP2, currentP3, timeToLive) 616 | else: 617 | _LOGGER.info("Circuit info must be initialized for dynamic current to be set") 618 | 619 | async def set_max_charger_circuit_current(self, currentP1: int, currentP2: int = None, currentP3: int = None): 620 | """Set circuit max current for charger""" 621 | if self.circuit is not None: 622 | return await self.circuit.set_max_current(currentP1, currentP2, currentP3) 623 | else: 624 | _LOGGER.info("Circuit info must be initialized for max current to be set") 625 | 626 | async def set_max_offline_charger_circuit_current( 627 | self, currentP1: int, currentP2: int = None, currentP3: int = None 628 | ): 629 | """Set circuit max offline current for charger, fallback value for limit if charger is offline""" 630 | if self.circuit is not None: 631 | return await self.circuit.set_max_offline_current(currentP1, currentP2, currentP3) 632 | else: 633 | _LOGGER.info("Circuit info must be initialized for offline current to be set") 634 | 635 | async def set_dynamic_charger_current(self, current: int, timeToLive: int = 0): 636 | """Set charger dynamic current""" 637 | json = {"amps": current, "minutes": timeToLive} 638 | try: 639 | return await self.easee.post(f"/api/chargers/{self.id}/commands/set_dynamic_charger_current", json=json) 640 | except (ServerFailureException): 641 | return None 642 | 643 | async def set_max_charger_current(self, current: int): 644 | """Set charger max current""" 645 | json = {"maxChargerCurrent": current} 646 | try: 647 | return await self.easee.post(f"/api/chargers/{self.id}/settings", json=json) 648 | except (ServerFailureException): 649 | return None 650 | 651 | async def set_led_strip_brightness(self, brightness: int): 652 | """Set LED strip brightness.""" 653 | json = {"ledStripBrightness": brightness} 654 | try: 655 | return await self.easee.post(f"/api/chargers/{self.id}/settings", json=json) 656 | except (ServerFailureException): 657 | return None 658 | 659 | async def set_access(self, access: Union[int, str]): 660 | """Set the level of access for a changer""" 661 | json = { 662 | 1: 1, 663 | 2: 2, 664 | 3: 3, 665 | "open_for_all": 1, 666 | "easee_account_required": 2, 667 | "whitelist": 3, 668 | } 669 | 670 | try: 671 | return await self.easee.put(f"/api/chargers/{self.id}/access", json=json[access]) 672 | except (ServerFailureException): 673 | return None 674 | 675 | async def delete_access(self): 676 | """Revert permissions overridden on a charger level""" 677 | try: 678 | return await self.easee.delete(f"/api/chargers/{self.id}/access") 679 | except (ServerFailureException): 680 | return None 681 | 682 | async def force_update_lifetimeenergy(self): 683 | """Forces charger to update Lifetime Energy reading to the cloud. Warning: Rate limited to once every 3 minutes.""" 684 | try: 685 | return await self.easee.post(f"/api/chargers/{self.id}/commands/poll_lifetimeenergy") 686 | except (ServerFailureException): 687 | return None 688 | 689 | async def force_update_opmode(self): 690 | """Forces charger to update Op Mode to the cloud. Warning: Rate limited to once every 3 minutes.""" 691 | try: 692 | return await self.easee.post(f"/api/chargers/{self.id}/commands/poll_chargeropmode") 693 | except (ServerFailureException): 694 | return None 695 | 696 | async def get_ocpp_config(self): 697 | """Reads the OCPP config of the charger""" 698 | try: 699 | return await (await self.easee.get(f"/local-ocpp/v1/connection-details/{self.id}")).json() 700 | except (NotFoundException): 701 | return None 702 | except (ServerFailureException): 703 | return None 704 | 705 | async def set_ocpp_config(self, enable, url): 706 | """Writes the OCPP config of the charger""" 707 | if enable: 708 | enablestr = "DualProtocol" 709 | else: 710 | enablestr = "OcppOff" 711 | json = {"connectivityMode": enablestr, "websocketConnectionArgs": {"url": url}} 712 | try: 713 | result = await (await self.easee.post(f"/local-ocpp/v1/connection-details/{self.id}", json=json)).json() 714 | return result["version"] 715 | except (ServerFailureException): 716 | return None 717 | 718 | async def apply_ocpp_config(self, version): 719 | """Applies a stored OCPP config of the charger""" 720 | json = {"version": version} 721 | try: 722 | return await (await self.easee.post(f"/local-ocpp/v1/connections/chargers/{self.id}", json=json)).json() 723 | except (ServerFailureException): 724 | return None 725 | --------------------------------------------------------------------------------