pybluecurrent 0.1.1__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pybluecurrent-0.2.0/CHANGELOG.md +40 -0
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/PKG-INFO +30 -2
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/README.md +29 -1
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent/_version.py +3 -3
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent/client.py +121 -50
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent.egg-info/PKG-INFO +30 -2
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent.egg-info/SOURCES.txt +11 -1
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent.egg-info/scm_file_list.json +10 -0
- pybluecurrent-0.2.0/src/pybluecurrent.egg-info/scm_version.json +8 -0
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/tests/conftest.py +16 -1
- pybluecurrent-0.2.0/tests/fake_socket.py +130 -0
- pybluecurrent-0.2.0/tests/fixtures/account.json +12 -0
- pybluecurrent-0.2.0/tests/fixtures/charge_cards.json +15 -0
- pybluecurrent-0.2.0/tests/fixtures/charge_point_settings.json +50 -0
- pybluecurrent-0.2.0/tests/fixtures/charge_points.json +75 -0
- pybluecurrent-0.2.0/tests/fixtures/error_forbidden.json +5 -0
- pybluecurrent-0.2.0/tests/fixtures/grid_status.json +11 -0
- pybluecurrent-0.2.0/tests/fixtures/sustainability_status.json +5 -0
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/tests/test_client.py +16 -2
- pybluecurrent-0.2.0/tests/test_offline.py +225 -0
- pybluecurrent-0.1.1/src/pybluecurrent.egg-info/scm_version.json +0 -8
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/.github/workflows/publish.yaml +0 -0
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/.github/workflows/test.yaml +0 -0
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/.gitignore +0 -0
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/.pre-commit-config.yaml +0 -0
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/LICENSE +0 -0
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/pyproject.toml +0 -0
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/setup.cfg +0 -0
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent/__init__.py +0 -0
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent/exceptions.py +0 -0
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent/py.typed +0 -0
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent/utilities.py +0 -0
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent.egg-info/dependency_links.txt +0 -0
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent.egg-info/requires.txt +0 -0
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent.egg-info/top_level.txt +0 -0
- {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/tests/test_utilities.py +0 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
|
+
This project is pre-1.0: breaking changes may land in minor releases.
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.2.0] - 2026-07-11
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- API-token authentication: construct the client with `BlueCurrentClient(api_token=...)` instead of a username and password.
|
|
15
|
+
- `get_api_token()` and `generate_api_token()` to fetch or rotate your account's API token (home automation key).
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- REST calls now use `api.bluecurrent.nl` instead of the legacy `bo.bluecurrent.nl` backoffice host (same `bc_api` v2.0 API and response shapes).
|
|
20
|
+
- REST requests now use a 30-second timeout instead of httpx's 5-second default, so occasional slow backend responses no longer raise `httpx.ReadTimeout`; override with the `http_timeout` attribute.
|
|
21
|
+
- Internal: added an offline websocket test harness (fake socket + recorded fixtures) so the auth/`_send`/`_receive`/`_handler` logic runs in CI without live credentials.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Concurrent websocket calls are now safe: calls awaiting the same response type are serialized so overlapping same-type calls can no longer receive each other's replies (different-type calls still run concurrently). Errors the backend tags with a request id are routed to the originating call rather than failing every in-flight call.
|
|
26
|
+
|
|
27
|
+
## [0.1.1] - 2026-07-04
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- `get_account` no longer raises a `ValueError` on BlueCurrent's current date format; `first_login_app` now parses both the legacy (`01-JAN-20`) and ISO (`2020-01-15T13:33:52`) formats.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- `get_account` returns `first_login_app` as a `datetime` (previously a `date`).
|
|
36
|
+
- Internal: switched tooling to Ruff and ty, added a Python 3.10–3.13 CI matrix, and moved to PyPI trusted publishing (OIDC).
|
|
37
|
+
|
|
38
|
+
[Unreleased]: https://github.com/rogiervandergeer/pybluecurrent/compare/0.2.0...HEAD
|
|
39
|
+
[0.2.0]: https://github.com/rogiervandergeer/pybluecurrent/compare/0.1.1...0.2.0
|
|
40
|
+
[0.1.1]: https://github.com/rogiervandergeer/pybluecurrent/compare/0.1.0...0.1.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pybluecurrent
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python client for BlueCurrent charge points.
|
|
5
5
|
Author-email: Rogier van der Geer <rogier@vander-geer.nl>
|
|
6
6
|
License: MIT
|
|
@@ -52,6 +52,8 @@ async with client:
|
|
|
52
52
|
The `BlueCurrentClient` exposes the following methods:
|
|
53
53
|
|
|
54
54
|
- [`get_account`](#getaccount---get-your-account-information)
|
|
55
|
+
- [`get_api_token`](#getapitoken---get-your-api-token)
|
|
56
|
+
- [`generate_api_token`](#generateapitoken---generate-a-new-api-token)
|
|
55
57
|
- [`get_charge_cards`](#getchargecards---get-your-charge-cards)
|
|
56
58
|
- [`get_charge_points`](#getchargepoints---get-your-charge-points)
|
|
57
59
|
- [`get_charge_point_settings`](#getchargepointsettings---get-the-settings-of-a-charge-point)
|
|
@@ -76,6 +78,14 @@ async with client:
|
|
|
76
78
|
```
|
|
77
79
|
Entering the async context will automatically login.
|
|
78
80
|
|
|
81
|
+
Instead of a username and password, you can authenticate with an API token:
|
|
82
|
+
```python
|
|
83
|
+
client = BlueCurrentClient(api_token="your_api_token")
|
|
84
|
+
```
|
|
85
|
+
Retrieve or rotate the token with [`get_api_token`](#getapitoken---get-your-api-token) and
|
|
86
|
+
[`generate_api_token`](#generateapitoken---generate-a-new-api-token), or from the
|
|
87
|
+
[BlueCurrent website](https://my.bluecurrent.nl).
|
|
88
|
+
|
|
79
89
|
#### `get_account` - Get your account information.
|
|
80
90
|
|
|
81
91
|
```python
|
|
@@ -94,11 +104,29 @@ A dictionary describing your account:
|
|
|
94
104
|
"developer_mode_enabled": False,
|
|
95
105
|
"tel": "",
|
|
96
106
|
"marketing_target": "bluecurrent",
|
|
97
|
-
"first_login_app":
|
|
107
|
+
"first_login_app": datetime(2020, 1, 15, 13, 33, 52),
|
|
98
108
|
"hubspot_user_identity": "a_very_long_string"
|
|
99
109
|
}
|
|
100
110
|
```
|
|
101
111
|
|
|
112
|
+
#### `get_api_token` - Get your API token.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
async def get_api_token(self) -> str
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Returns the API token (home automation key) for your account. This token can be used to authenticate
|
|
119
|
+
instead of a username and password, by constructing the client with `BlueCurrentClient(api_token=...)`.
|
|
120
|
+
|
|
121
|
+
#### `generate_api_token` - Generate a new API token.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
async def generate_api_token(self) -> str
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Generates a new API token and returns it. **Warning:** this rotates the token — any previously issued
|
|
128
|
+
token is invalidated, which will break anything still using the old one (for example a Home Assistant integration).
|
|
129
|
+
|
|
102
130
|
#### `get_charge_cards` - Get your charge cards.
|
|
103
131
|
|
|
104
132
|
```python
|
|
@@ -25,6 +25,8 @@ async with client:
|
|
|
25
25
|
The `BlueCurrentClient` exposes the following methods:
|
|
26
26
|
|
|
27
27
|
- [`get_account`](#getaccount---get-your-account-information)
|
|
28
|
+
- [`get_api_token`](#getapitoken---get-your-api-token)
|
|
29
|
+
- [`generate_api_token`](#generateapitoken---generate-a-new-api-token)
|
|
28
30
|
- [`get_charge_cards`](#getchargecards---get-your-charge-cards)
|
|
29
31
|
- [`get_charge_points`](#getchargepoints---get-your-charge-points)
|
|
30
32
|
- [`get_charge_point_settings`](#getchargepointsettings---get-the-settings-of-a-charge-point)
|
|
@@ -49,6 +51,14 @@ async with client:
|
|
|
49
51
|
```
|
|
50
52
|
Entering the async context will automatically login.
|
|
51
53
|
|
|
54
|
+
Instead of a username and password, you can authenticate with an API token:
|
|
55
|
+
```python
|
|
56
|
+
client = BlueCurrentClient(api_token="your_api_token")
|
|
57
|
+
```
|
|
58
|
+
Retrieve or rotate the token with [`get_api_token`](#getapitoken---get-your-api-token) and
|
|
59
|
+
[`generate_api_token`](#generateapitoken---generate-a-new-api-token), or from the
|
|
60
|
+
[BlueCurrent website](https://my.bluecurrent.nl).
|
|
61
|
+
|
|
52
62
|
#### `get_account` - Get your account information.
|
|
53
63
|
|
|
54
64
|
```python
|
|
@@ -67,11 +77,29 @@ A dictionary describing your account:
|
|
|
67
77
|
"developer_mode_enabled": False,
|
|
68
78
|
"tel": "",
|
|
69
79
|
"marketing_target": "bluecurrent",
|
|
70
|
-
"first_login_app":
|
|
80
|
+
"first_login_app": datetime(2020, 1, 15, 13, 33, 52),
|
|
71
81
|
"hubspot_user_identity": "a_very_long_string"
|
|
72
82
|
}
|
|
73
83
|
```
|
|
74
84
|
|
|
85
|
+
#### `get_api_token` - Get your API token.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
async def get_api_token(self) -> str
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Returns the API token (home automation key) for your account. This token can be used to authenticate
|
|
92
|
+
instead of a username and password, by constructing the client with `BlueCurrentClient(api_token=...)`.
|
|
93
|
+
|
|
94
|
+
#### `generate_api_token` - Generate a new API token.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
async def generate_api_token(self) -> str
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Generates a new API token and returns it. **Warning:** this rotates the token — any previously issued
|
|
101
|
+
token is invalidated, which will break anything still using the old one (for example a Home Assistant integration).
|
|
102
|
+
|
|
75
103
|
#### `get_charge_cards` - Get your charge cards.
|
|
76
104
|
|
|
77
105
|
```python
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'ge8d24aba3'
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
from asyncio import Task, create_task, wait_for
|
|
1
|
+
from asyncio import Lock, Task, create_task, wait_for
|
|
2
|
+
from asyncio import TimeoutError as AsyncTimeoutError
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
2
5
|
from datetime import date, datetime
|
|
3
6
|
from json import dumps, loads
|
|
4
7
|
from logging import getLogger
|
|
5
|
-
from typing import Any, AsyncIterable
|
|
8
|
+
from typing import Any, AsyncIterable, AsyncIterator
|
|
6
9
|
from uuid import uuid4
|
|
7
10
|
|
|
8
11
|
from asyncio_multisubscriber_queue import MultisubscriberQueue
|
|
@@ -16,16 +19,22 @@ from pybluecurrent.utilities import parse_datetime_keys, parse_list_datetime_key
|
|
|
16
19
|
|
|
17
20
|
|
|
18
21
|
class BlueCurrentClient:
|
|
19
|
-
api_url: str = "https://
|
|
22
|
+
api_url: str = "https://api.bluecurrent.nl/app/bc_api/api/v2.0"
|
|
20
23
|
psk: str = "d9ab2352a935be4ade182ce4921044f8"
|
|
21
24
|
socket_url: str = "wss://motown.bluecurrent.nl/appserver/2.0"
|
|
25
|
+
http_timeout: float = 30.0
|
|
22
26
|
|
|
23
|
-
def __init__(self, username: str, password: str):
|
|
27
|
+
def __init__(self, username: str | None = None, password: str | None = None, api_token: str | None = None):
|
|
28
|
+
if api_token is None and (username is None or password is None):
|
|
29
|
+
raise ValueError("Provide either username and password, or api_token.")
|
|
24
30
|
self.consumer: Task | None = None
|
|
25
|
-
self.credentials: tuple[str, str] = (username, password)
|
|
31
|
+
self.credentials: tuple[str | None, str | None] = (username, password)
|
|
32
|
+
self.api_token: str | None = api_token
|
|
33
|
+
self.customer_id: str | None = None
|
|
26
34
|
self.logger = getLogger("BlueCurrentClient")
|
|
27
35
|
self.httpx_client: AsyncClient | None = None
|
|
28
36
|
self.queue = MultisubscriberQueue()
|
|
37
|
+
self.locks: defaultdict[str, Lock] = defaultdict(Lock)
|
|
29
38
|
self.socket: ClientConnection | None = None
|
|
30
39
|
self.token: str | None = None
|
|
31
40
|
|
|
@@ -34,7 +43,7 @@ class BlueCurrentClient:
|
|
|
34
43
|
self.connection = connect(self.socket_url, user_agent_header=self._user_agent)
|
|
35
44
|
self.socket = await self.connection.__aenter__()
|
|
36
45
|
self.consumer = create_task(self._handler())
|
|
37
|
-
self.httpx_client = AsyncClient()
|
|
46
|
+
self.httpx_client = AsyncClient(timeout=self.http_timeout)
|
|
38
47
|
await self.httpx_client.__aenter__()
|
|
39
48
|
if self.token is None:
|
|
40
49
|
await self._login()
|
|
@@ -68,11 +77,42 @@ class BlueCurrentClient:
|
|
|
68
77
|
"hubspot_user_identity": "a_very_long_string"
|
|
69
78
|
}
|
|
70
79
|
"""
|
|
71
|
-
await self.
|
|
72
|
-
result = await self._receive("ACCOUNT")
|
|
80
|
+
result = await self._request(dict(command="GET_ACCOUNT"), "ACCOUNT")
|
|
73
81
|
del result["object"]
|
|
74
82
|
return parse_datetime_keys(result, formats={"first_login_app": (("%d-%b-%y", "%Y-%m-%dT%H:%M:%S"), False)})
|
|
75
83
|
|
|
84
|
+
async def get_api_token(self) -> str:
|
|
85
|
+
"""
|
|
86
|
+
Get the API token (home automation key) for your account.
|
|
87
|
+
|
|
88
|
+
The token can be used to authenticate instead of a username and password, by constructing
|
|
89
|
+
the client with ``BlueCurrentClient(api_token=...)``.
|
|
90
|
+
"""
|
|
91
|
+
if self.httpx_client is None:
|
|
92
|
+
raise RuntimeError(f"{self.__class__.__name__} is not connected.")
|
|
93
|
+
response = await self.httpx_client.get(
|
|
94
|
+
f"{self.api_url}/gethomeautomationkey",
|
|
95
|
+
headers={"Authorization": f"Token {self.token}", "User-Agent": self._user_agent},
|
|
96
|
+
)
|
|
97
|
+
response.raise_for_status()
|
|
98
|
+
return response.json()["key"]
|
|
99
|
+
|
|
100
|
+
async def generate_api_token(self) -> str:
|
|
101
|
+
"""
|
|
102
|
+
Generate a new API token (home automation key) and return it.
|
|
103
|
+
|
|
104
|
+
Warning: this rotates the token. Any previously issued token is invalidated, which will
|
|
105
|
+
break anything still using the old one (for example a Home Assistant integration).
|
|
106
|
+
"""
|
|
107
|
+
if self.httpx_client is None:
|
|
108
|
+
raise RuntimeError(f"{self.__class__.__name__} is not connected.")
|
|
109
|
+
response = await self.httpx_client.post(
|
|
110
|
+
f"{self.api_url}/generatehomeautomationkey",
|
|
111
|
+
headers={"Authorization": f"Token {self.token}", "User-Agent": self._user_agent},
|
|
112
|
+
)
|
|
113
|
+
response.raise_for_status()
|
|
114
|
+
return await self.get_api_token()
|
|
115
|
+
|
|
76
116
|
async def get_charge_cards(self) -> list[dict[str, date | int | str | None]]:
|
|
77
117
|
"""
|
|
78
118
|
Get your charge cards:
|
|
@@ -82,7 +122,7 @@ class BlueCurrentClient:
|
|
|
82
122
|
{
|
|
83
123
|
"uid": "A1B2C3D4E5F6",
|
|
84
124
|
"id": "NL-ABC-123456-0",
|
|
85
|
-
"name": "My Charge
|
|
125
|
+
"name": "My Charge Card",
|
|
86
126
|
"customer_name": "Your Name",
|
|
87
127
|
"valid": 1,
|
|
88
128
|
"date_created": date(2023, 6, 27),
|
|
@@ -90,8 +130,7 @@ class BlueCurrentClient:
|
|
|
90
130
|
"date_became_invalid": None
|
|
91
131
|
}
|
|
92
132
|
"""
|
|
93
|
-
await self.
|
|
94
|
-
result = (await self._receive("CHARGE_CARDS"))["cards"]
|
|
133
|
+
result = (await self._request(dict(command="GET_CHARGE_CARDS"), "CHARGE_CARDS"))["cards"]
|
|
95
134
|
return parse_list_datetime_keys(
|
|
96
135
|
result,
|
|
97
136
|
formats={
|
|
@@ -134,8 +173,7 @@ class BlueCurrentClient:
|
|
|
134
173
|
"delayed_charging": {"value": False, "permission": "none"}
|
|
135
174
|
}
|
|
136
175
|
"""
|
|
137
|
-
await self.
|
|
138
|
-
return (await self._receive("CHARGE_POINTS"))["data"]
|
|
176
|
+
return (await self._request(dict(command="GET_CHARGE_POINTS"), "CHARGE_POINTS"))["data"]
|
|
139
177
|
|
|
140
178
|
async def get_charge_point_settings(self, evse_id: str) -> dict[str, bool | dict[str, Any] | str]:
|
|
141
179
|
"""
|
|
@@ -168,8 +206,7 @@ class BlueCurrentClient:
|
|
|
168
206
|
"led_interaction": {"value": False, "permission": "none"}
|
|
169
207
|
}
|
|
170
208
|
"""
|
|
171
|
-
await self.
|
|
172
|
-
return (await self._receive("CH_SETTINGS"))["data"]
|
|
209
|
+
return (await self._request(dict(command="GET_CH_SETTINGS", evse_id=evse_id), "CH_SETTINGS"))["data"]
|
|
173
210
|
|
|
174
211
|
async def get_grid_status(self, evse_id: str) -> dict[str, int | str]:
|
|
175
212
|
"""
|
|
@@ -189,13 +226,11 @@ class BlueCurrentClient:
|
|
|
189
226
|
"grid_max_reserved": 25
|
|
190
227
|
}
|
|
191
228
|
"""
|
|
192
|
-
await self.
|
|
193
|
-
return (await self._receive("GRID_STATUS"))["data"]
|
|
229
|
+
return (await self._request(dict(command="GET_GRID_STATUS", evse_id=evse_id), "GRID_STATUS"))["data"]
|
|
194
230
|
|
|
195
231
|
async def get_sessions(self, evse_id: str):
|
|
196
232
|
"""Does not work"""
|
|
197
|
-
await self.
|
|
198
|
-
return await self._receive("SESSIONS")
|
|
233
|
+
return await self._request(dict(command="GET_SESSIONS"), "SESSIONS")
|
|
199
234
|
|
|
200
235
|
async def get_sustainability_status(self) -> dict[str, float | int]:
|
|
201
236
|
"""
|
|
@@ -205,8 +240,7 @@ class BlueCurrentClient:
|
|
|
205
240
|
A dictionary with two keys:
|
|
206
241
|
{"trees": 1, "co2": 12.345}
|
|
207
242
|
"""
|
|
208
|
-
await self.
|
|
209
|
-
result = await self._receive("SUSTAINABILITY_STATUS")
|
|
243
|
+
result = await self._request(dict(command="GET_SUSTAINABILITY_STATUS"), "SUSTAINABILITY_STATUS")
|
|
210
244
|
result.pop("object")
|
|
211
245
|
return result
|
|
212
246
|
|
|
@@ -224,11 +258,10 @@ class BlueCurrentClient:
|
|
|
224
258
|
as setting it to None.
|
|
225
259
|
"""
|
|
226
260
|
token_uid = "BCU-APP" if uid is None or uid == "BCU_HOME_USE" else uid
|
|
227
|
-
await self.
|
|
261
|
+
result = await self._request(
|
|
228
262
|
dict(command="SET_PLUG_AND_CHARGE_CHARGE_CARD", evse_id=evse_id, token_uid=token_uid),
|
|
229
|
-
|
|
263
|
+
"STATUS_SET_PLUG_AND_CHARGE_CHARGE_CARD",
|
|
230
264
|
)
|
|
231
|
-
result = await self._receive("STATUS_SET_PLUG_AND_CHARGE_CHARGE_CARD")
|
|
232
265
|
if not result.get("success"):
|
|
233
266
|
raise BlueCurrentException(result)
|
|
234
267
|
|
|
@@ -240,32 +273,26 @@ class BlueCurrentClient:
|
|
|
240
273
|
evse_id: The ID of the charge point.
|
|
241
274
|
enabled: Boolean that indicates the desired status.
|
|
242
275
|
"""
|
|
243
|
-
if enabled
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
await self.
|
|
247
|
-
|
|
248
|
-
await self.
|
|
249
|
-
await self._receive("RECEIVED_SET_INOPERATIVE")
|
|
250
|
-
await self._receive("STATUS_SET_INOPERATIVE", timeout=30)
|
|
276
|
+
command = "SET_OPERATIVE" if enabled else "SET_INOPERATIVE"
|
|
277
|
+
flow_id = str(uuid4())
|
|
278
|
+
async with self._command(command):
|
|
279
|
+
await self._send(dict(command=command, evse_id=evse_id, flow_id=flow_id), token=True)
|
|
280
|
+
await self._receive(f"RECEIVED_{command}", flow_id=flow_id)
|
|
281
|
+
await self._receive(f"STATUS_{command}", timeout=30, flow_id=flow_id)
|
|
251
282
|
|
|
252
283
|
async def unlock_connector(self, evse_id: str):
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
dict(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
),
|
|
259
|
-
token=True,
|
|
260
|
-
)
|
|
261
|
-
await self._receive("RECEIVED_UNLOCK_CONNECTOR")
|
|
262
|
-
return await self._receive("STATUS_UNLOCK_CONNECTOR", timeout=30)
|
|
284
|
+
flow_id = str(uuid4())
|
|
285
|
+
async with self._command("UNLOCK_CONNECTOR"):
|
|
286
|
+
await self._send(dict(command="UNLOCK_CONNECTOR", evse_id=evse_id, flow_id=flow_id), token=True)
|
|
287
|
+
await self._receive("RECEIVED_UNLOCK_CONNECTOR", flow_id=flow_id)
|
|
288
|
+
return await self._receive("STATUS_UNLOCK_CONNECTOR", timeout=30, flow_id=flow_id)
|
|
263
289
|
|
|
264
290
|
async def soft_reset(self, evse_id: str):
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
291
|
+
flow_id = str(uuid4())
|
|
292
|
+
async with self._command("SOFT_RESET"):
|
|
293
|
+
await self._send(dict(command="SOFT_RESET", evse_id=evse_id, flow_id=flow_id), token=True)
|
|
294
|
+
await self._receive("RECEIVED_SOFT_RESET", flow_id=flow_id)
|
|
295
|
+
return await self._receive("STATUS_SOFT_RESET", timeout=30, flow_id=flow_id)
|
|
269
296
|
|
|
270
297
|
async def get_charge_point_status(self, evse_id: str) -> dict[str, datetime | float | int | str | None]:
|
|
271
298
|
"""
|
|
@@ -448,6 +475,12 @@ class BlueCurrentClient:
|
|
|
448
475
|
next_page = transactions["next_page"]
|
|
449
476
|
|
|
450
477
|
async def _login(self) -> None:
|
|
478
|
+
if self.api_token is not None:
|
|
479
|
+
await self._login_with_token()
|
|
480
|
+
else:
|
|
481
|
+
await self._login_with_password()
|
|
482
|
+
|
|
483
|
+
async def _login_with_password(self) -> None:
|
|
451
484
|
await self._send(
|
|
452
485
|
dict(
|
|
453
486
|
command="VALIDATE_PASSWORD",
|
|
@@ -462,15 +495,28 @@ class BlueCurrentClient:
|
|
|
462
495
|
self.token = message["token"]
|
|
463
496
|
self.logger.info("Successfully authenticated")
|
|
464
497
|
|
|
498
|
+
async def _login_with_token(self) -> None:
|
|
499
|
+
await self._send(dict(command="VALIDATE_API_TOKEN", token=self.api_token))
|
|
500
|
+
message = await self._receive("STATUS_API_TOKEN")
|
|
501
|
+
if not message.get("success"):
|
|
502
|
+
self.logger.error("Authentication failed")
|
|
503
|
+
raise AuthenticationFailed(message)
|
|
504
|
+
self.token = message["token"]
|
|
505
|
+
self.customer_id = message.get("customer_id")
|
|
506
|
+
self.logger.info("Successfully authenticated")
|
|
507
|
+
|
|
465
508
|
async def _hello(self) -> None:
|
|
466
509
|
await self._send(dict(command="HELLO"), token=True)
|
|
467
510
|
await self._receive("HELLO")
|
|
468
511
|
|
|
469
512
|
def _encrypt_password(self) -> str:
|
|
513
|
+
password = self.credentials[1]
|
|
514
|
+
if password is None:
|
|
515
|
+
raise RuntimeError("No password configured.")
|
|
470
516
|
return dumps(
|
|
471
517
|
{
|
|
472
518
|
key: (value.decode("utf-8") if isinstance(value, bytes) else value)
|
|
473
|
-
for key, value in SJCL().encrypt(
|
|
519
|
+
for key, value in SJCL().encrypt(password.encode("utf-8"), self.psk).items()
|
|
474
520
|
},
|
|
475
521
|
ensure_ascii=False,
|
|
476
522
|
)
|
|
@@ -486,12 +532,24 @@ class BlueCurrentClient:
|
|
|
486
532
|
def _user_agent(self) -> str:
|
|
487
533
|
return f"pybluecurrent {__version__.split('+')[0]}"
|
|
488
534
|
|
|
489
|
-
async def _receive(self, obj: str, timeout: int = 10) -> dict[str, Any]:
|
|
535
|
+
async def _receive(self, obj: str, timeout: int = 10, flow_id: str | None = None) -> dict[str, Any]:
|
|
490
536
|
with self.queue.queue() as q:
|
|
491
537
|
while True:
|
|
492
|
-
|
|
538
|
+
try:
|
|
539
|
+
message = await wait_for(q.get(), timeout=timeout)
|
|
540
|
+
except AsyncTimeoutError as exc:
|
|
541
|
+
# On Python 3.10 asyncio.TimeoutError is a distinct class from the
|
|
542
|
+
# builtin TimeoutError; normalise so callers can catch the builtin.
|
|
543
|
+
raise TimeoutError from exc
|
|
493
544
|
if message.get("object") == "ERROR":
|
|
494
|
-
|
|
545
|
+
# Attribute errors by flow_id when the backend echoes one, so a correlated
|
|
546
|
+
# error doesn't poison other concurrent calls. Not every error carries a
|
|
547
|
+
# flow_id (e.g. "forbidden" has none), so an uncorrelated error still raises
|
|
548
|
+
# for whoever is waiting — falling back to the old broadcast behaviour.
|
|
549
|
+
error_flow_id = message.get("flow_id")
|
|
550
|
+
if error_flow_id in (flow_id, None):
|
|
551
|
+
raise BlueCurrentException(message)
|
|
552
|
+
continue
|
|
495
553
|
if message.get("object") == obj:
|
|
496
554
|
return message
|
|
497
555
|
|
|
@@ -501,3 +559,16 @@ class BlueCurrentClient:
|
|
|
501
559
|
if self.socket is None:
|
|
502
560
|
raise RuntimeError(f"{self.__class__.__name__} is not connected.")
|
|
503
561
|
await self.socket.send(dumps(data, ensure_ascii=False))
|
|
562
|
+
|
|
563
|
+
@asynccontextmanager
|
|
564
|
+
async def _command(self, key: str) -> AsyncIterator[None]:
|
|
565
|
+
# Serialise calls that await the same response object so concurrent same-type calls
|
|
566
|
+
# can't consume each other's replies. Different keys keep running concurrently, so a
|
|
567
|
+
# slow command (e.g. soft_reset) doesn't block a quick read.
|
|
568
|
+
async with self.locks[key]:
|
|
569
|
+
yield
|
|
570
|
+
|
|
571
|
+
async def _request(self, data: dict[str, Any], response_object: str, timeout: int = 10) -> dict[str, Any]:
|
|
572
|
+
async with self._command(response_object):
|
|
573
|
+
await self._send(data, token=True)
|
|
574
|
+
return await self._receive(response_object, timeout=timeout)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pybluecurrent
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python client for BlueCurrent charge points.
|
|
5
5
|
Author-email: Rogier van der Geer <rogier@vander-geer.nl>
|
|
6
6
|
License: MIT
|
|
@@ -52,6 +52,8 @@ async with client:
|
|
|
52
52
|
The `BlueCurrentClient` exposes the following methods:
|
|
53
53
|
|
|
54
54
|
- [`get_account`](#getaccount---get-your-account-information)
|
|
55
|
+
- [`get_api_token`](#getapitoken---get-your-api-token)
|
|
56
|
+
- [`generate_api_token`](#generateapitoken---generate-a-new-api-token)
|
|
55
57
|
- [`get_charge_cards`](#getchargecards---get-your-charge-cards)
|
|
56
58
|
- [`get_charge_points`](#getchargepoints---get-your-charge-points)
|
|
57
59
|
- [`get_charge_point_settings`](#getchargepointsettings---get-the-settings-of-a-charge-point)
|
|
@@ -76,6 +78,14 @@ async with client:
|
|
|
76
78
|
```
|
|
77
79
|
Entering the async context will automatically login.
|
|
78
80
|
|
|
81
|
+
Instead of a username and password, you can authenticate with an API token:
|
|
82
|
+
```python
|
|
83
|
+
client = BlueCurrentClient(api_token="your_api_token")
|
|
84
|
+
```
|
|
85
|
+
Retrieve or rotate the token with [`get_api_token`](#getapitoken---get-your-api-token) and
|
|
86
|
+
[`generate_api_token`](#generateapitoken---generate-a-new-api-token), or from the
|
|
87
|
+
[BlueCurrent website](https://my.bluecurrent.nl).
|
|
88
|
+
|
|
79
89
|
#### `get_account` - Get your account information.
|
|
80
90
|
|
|
81
91
|
```python
|
|
@@ -94,11 +104,29 @@ A dictionary describing your account:
|
|
|
94
104
|
"developer_mode_enabled": False,
|
|
95
105
|
"tel": "",
|
|
96
106
|
"marketing_target": "bluecurrent",
|
|
97
|
-
"first_login_app":
|
|
107
|
+
"first_login_app": datetime(2020, 1, 15, 13, 33, 52),
|
|
98
108
|
"hubspot_user_identity": "a_very_long_string"
|
|
99
109
|
}
|
|
100
110
|
```
|
|
101
111
|
|
|
112
|
+
#### `get_api_token` - Get your API token.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
async def get_api_token(self) -> str
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Returns the API token (home automation key) for your account. This token can be used to authenticate
|
|
119
|
+
instead of a username and password, by constructing the client with `BlueCurrentClient(api_token=...)`.
|
|
120
|
+
|
|
121
|
+
#### `generate_api_token` - Generate a new API token.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
async def generate_api_token(self) -> str
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Generates a new API token and returns it. **Warning:** this rotates the token — any previously issued
|
|
128
|
+
token is invalidated, which will break anything still using the old one (for example a Home Assistant integration).
|
|
129
|
+
|
|
102
130
|
#### `get_charge_cards` - Get your charge cards.
|
|
103
131
|
|
|
104
132
|
```python
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
.gitignore
|
|
2
2
|
.pre-commit-config.yaml
|
|
3
|
+
CHANGELOG.md
|
|
3
4
|
LICENSE
|
|
4
5
|
README.md
|
|
5
6
|
pyproject.toml
|
|
@@ -19,5 +20,14 @@ src/pybluecurrent.egg-info/scm_file_list.json
|
|
|
19
20
|
src/pybluecurrent.egg-info/scm_version.json
|
|
20
21
|
src/pybluecurrent.egg-info/top_level.txt
|
|
21
22
|
tests/conftest.py
|
|
23
|
+
tests/fake_socket.py
|
|
22
24
|
tests/test_client.py
|
|
23
|
-
tests/
|
|
25
|
+
tests/test_offline.py
|
|
26
|
+
tests/test_utilities.py
|
|
27
|
+
tests/fixtures/account.json
|
|
28
|
+
tests/fixtures/charge_cards.json
|
|
29
|
+
tests/fixtures/charge_point_settings.json
|
|
30
|
+
tests/fixtures/charge_points.json
|
|
31
|
+
tests/fixtures/error_forbidden.json
|
|
32
|
+
tests/fixtures/grid_status.json
|
|
33
|
+
tests/fixtures/sustainability_status.json
|
|
@@ -4,15 +4,25 @@
|
|
|
4
4
|
"README.md",
|
|
5
5
|
"LICENSE",
|
|
6
6
|
"pyproject.toml",
|
|
7
|
+
"CHANGELOG.md",
|
|
7
8
|
".gitignore",
|
|
8
9
|
"src/pybluecurrent/utilities.py",
|
|
9
10
|
"src/pybluecurrent/py.typed",
|
|
10
11
|
"src/pybluecurrent/__init__.py",
|
|
11
12
|
"src/pybluecurrent/exceptions.py",
|
|
12
13
|
"src/pybluecurrent/client.py",
|
|
14
|
+
"tests/test_offline.py",
|
|
13
15
|
"tests/test_client.py",
|
|
14
16
|
"tests/test_utilities.py",
|
|
17
|
+
"tests/fake_socket.py",
|
|
15
18
|
"tests/conftest.py",
|
|
19
|
+
"tests/fixtures/charge_point_settings.json",
|
|
20
|
+
"tests/fixtures/error_forbidden.json",
|
|
21
|
+
"tests/fixtures/sustainability_status.json",
|
|
22
|
+
"tests/fixtures/account.json",
|
|
23
|
+
"tests/fixtures/charge_points.json",
|
|
24
|
+
"tests/fixtures/grid_status.json",
|
|
25
|
+
"tests/fixtures/charge_cards.json",
|
|
16
26
|
".github/workflows/publish.yaml",
|
|
17
27
|
".github/workflows/test.yaml"
|
|
18
28
|
]
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from os import environ
|
|
2
2
|
from typing import AsyncGenerator
|
|
3
3
|
|
|
4
|
-
from
|
|
4
|
+
from fake_socket import FakeSocket, make_fake_connect
|
|
5
|
+
from pytest import MonkeyPatch, fixture, skip
|
|
5
6
|
|
|
6
7
|
from pybluecurrent import BlueCurrentClient
|
|
7
8
|
|
|
@@ -11,6 +12,20 @@ def client() -> BlueCurrentClient:
|
|
|
11
12
|
return BlueCurrentClient("username", "password")
|
|
12
13
|
|
|
13
14
|
|
|
15
|
+
@fixture(scope="function")
|
|
16
|
+
def fake_socket() -> FakeSocket:
|
|
17
|
+
"""A fresh offline websocket with the default auth + HELLO handshake scripted."""
|
|
18
|
+
return FakeSocket()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@fixture(scope="function")
|
|
22
|
+
async def offline_client(monkeypatch: MonkeyPatch, fake_socket: FakeSocket) -> AsyncGenerator[BlueCurrentClient, None]:
|
|
23
|
+
"""A connected client backed by ``fake_socket`` — no credentials, no network."""
|
|
24
|
+
monkeypatch.setattr("pybluecurrent.client.connect", make_fake_connect(fake_socket))
|
|
25
|
+
async with BlueCurrentClient("username", "password") as client:
|
|
26
|
+
yield client
|
|
27
|
+
|
|
28
|
+
|
|
14
29
|
@fixture(scope="session")
|
|
15
30
|
def client_with_auth() -> BlueCurrentClient | None:
|
|
16
31
|
try:
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Offline fake for the BlueCurrent websocket transport.
|
|
2
|
+
|
|
3
|
+
Replaces ``websockets.asyncio.client.connect`` in tests so the client can run without a
|
|
4
|
+
live backend. Patch it in with::
|
|
5
|
+
|
|
6
|
+
monkeypatch.setattr("pybluecurrent.client.connect", make_fake_connect(fake_socket))
|
|
7
|
+
|
|
8
|
+
The client only uses the socket in a handful of ways (async iteration in ``_handler``,
|
|
9
|
+
``send`` in ``_send``, and the ``connect(...)`` async context manager in ``__aenter__``),
|
|
10
|
+
so ``FakeSocket`` duck-types just those.
|
|
11
|
+
|
|
12
|
+
Two ways to drive responses:
|
|
13
|
+
|
|
14
|
+
* **Scripted** — a ``responder`` maps a command name to the frames the server sends back.
|
|
15
|
+
A default responder scripts the auth handshake and ``HELLO`` so ``async with client``
|
|
16
|
+
completes offline. Register more with :meth:`FakeSocket.on`.
|
|
17
|
+
* **Manual** — a command with no script feeds nothing; the test injects frames itself with
|
|
18
|
+
:meth:`FakeSocket.feed` (in any order, duplicated, as ``ERROR``, or as raw non-JSON).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from asyncio import Queue
|
|
22
|
+
from json import dumps, loads
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Callable
|
|
25
|
+
|
|
26
|
+
Responder = Callable[[dict[str, Any]], list[dict[str, Any]]]
|
|
27
|
+
|
|
28
|
+
_FIXTURE_DIR = Path(__file__).parent / "fixtures"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_fixture(name: str) -> dict[str, Any]:
|
|
32
|
+
"""Load a recorded server frame from ``tests/fixtures`` (``.json`` optional)."""
|
|
33
|
+
path = _FIXTURE_DIR / (name if name.endswith(".json") else f"{name}.json")
|
|
34
|
+
return loads(path.read_text())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Token the default handshake hands back; tests can assert against it.
|
|
38
|
+
FAKE_TOKEN = "fake-token-123"
|
|
39
|
+
FAKE_CUSTOMER_ID = "fake-customer-1"
|
|
40
|
+
|
|
41
|
+
# Sentinel enqueued by close() to end the ``async for`` loop in ``_handler``.
|
|
42
|
+
_DISCONNECT = object()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def default_responder() -> dict[str, Responder]:
|
|
46
|
+
"""A responder scripting the auth handshake and ``HELLO`` (enough for ``__aenter__``)."""
|
|
47
|
+
return {
|
|
48
|
+
"VALIDATE_PASSWORD": lambda msg: [{"object": "STATUS_PASSWORD", "accepted": True, "token": FAKE_TOKEN}],
|
|
49
|
+
"VALIDATE_API_TOKEN": lambda msg: [
|
|
50
|
+
{"object": "STATUS_API_TOKEN", "success": True, "token": FAKE_TOKEN, "customer_id": FAKE_CUSTOMER_ID}
|
|
51
|
+
],
|
|
52
|
+
"HELLO": lambda msg: [{"object": "HELLO"}],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class FakeSocket:
|
|
57
|
+
"""Duck-typed stand-in for a ``websockets`` client connection."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, responder: dict[str, Responder] | None = None) -> None:
|
|
60
|
+
self._outbound: Queue[Any] = Queue()
|
|
61
|
+
self.sent: list[dict[str, Any]] = []
|
|
62
|
+
self.responder: dict[str, Responder] = default_responder() if responder is None else responder
|
|
63
|
+
self.closed = False
|
|
64
|
+
|
|
65
|
+
# -- server -> client -------------------------------------------------
|
|
66
|
+
def feed(self, frame: dict[str, Any] | str) -> None:
|
|
67
|
+
"""Push a server->client frame. A dict is JSON-encoded; a str is sent verbatim.
|
|
68
|
+
|
|
69
|
+
Raw strings let tests exercise the non-JSON-frame path (``_handler`` calls ``loads``).
|
|
70
|
+
"""
|
|
71
|
+
self._outbound.put_nowait(frame if isinstance(frame, str) else dumps(frame, ensure_ascii=False))
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
"""Signal a disconnect so the ``async for`` in ``_handler`` ends cleanly."""
|
|
75
|
+
self.closed = True
|
|
76
|
+
self._outbound.put_nowait(_DISCONNECT)
|
|
77
|
+
|
|
78
|
+
def __aiter__(self) -> "FakeSocket":
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
async def __anext__(self) -> str:
|
|
82
|
+
frame = await self._outbound.get()
|
|
83
|
+
if frame is _DISCONNECT:
|
|
84
|
+
raise StopAsyncIteration
|
|
85
|
+
return frame
|
|
86
|
+
|
|
87
|
+
# -- client -> server -------------------------------------------------
|
|
88
|
+
async def send(self, raw: str) -> None:
|
|
89
|
+
"""Record the outgoing message and feed any scripted responses.
|
|
90
|
+
|
|
91
|
+
Uses ``put_nowait`` and never suspends, so the caller resumes synchronously and
|
|
92
|
+
subscribes to the response queue before the handler task runs — mirroring the fact
|
|
93
|
+
that real network latency keeps ``send`` ahead of the reply.
|
|
94
|
+
"""
|
|
95
|
+
message = loads(raw)
|
|
96
|
+
self.sent.append(message)
|
|
97
|
+
handler = self.responder.get(message.get("command"))
|
|
98
|
+
if handler is not None:
|
|
99
|
+
for frame in handler(message):
|
|
100
|
+
self.feed(frame)
|
|
101
|
+
|
|
102
|
+
def on(self, command: str, *frames: dict[str, Any]) -> None:
|
|
103
|
+
"""Script the frames returned when ``command`` is sent."""
|
|
104
|
+
self.responder[command] = lambda msg: list(frames)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class FakeConnection:
|
|
108
|
+
"""Async context manager returned by :func:`make_fake_connect`, yielding a FakeSocket."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, socket: FakeSocket) -> None:
|
|
111
|
+
self.socket = socket
|
|
112
|
+
|
|
113
|
+
async def __aenter__(self) -> FakeSocket:
|
|
114
|
+
return self.socket
|
|
115
|
+
|
|
116
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
117
|
+
self.socket.close()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def make_fake_connect(socket: FakeSocket) -> Callable[..., FakeConnection]:
|
|
121
|
+
"""Build a drop-in replacement for ``connect`` bound to ``socket``.
|
|
122
|
+
|
|
123
|
+
The returned callable accepts (and ignores) the ``url`` / ``user_agent_header`` args the
|
|
124
|
+
client passes, and returns a :class:`FakeConnection`.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def fake_connect(*args: Any, **kwargs: Any) -> FakeConnection:
|
|
128
|
+
return FakeConnection(socket)
|
|
129
|
+
|
|
130
|
+
return fake_connect
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"developer_mode_enabled": false,
|
|
3
|
+
"email": "your@email.address",
|
|
4
|
+
"first_login_app": "2020-01-15T13:33:52",
|
|
5
|
+
"full_name": "Your Full Name",
|
|
6
|
+
"hubspot_user_identity": "a_very_long_string",
|
|
7
|
+
"login": "your@email.address",
|
|
8
|
+
"marketing_target": "bluecurrent",
|
|
9
|
+
"object": "ACCOUNT",
|
|
10
|
+
"should_reset_password": false,
|
|
11
|
+
"tel": ""
|
|
12
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cards": [
|
|
3
|
+
{
|
|
4
|
+
"customer_name": "Your Name",
|
|
5
|
+
"date_became_invalid": null,
|
|
6
|
+
"date_created": "2023-06-27",
|
|
7
|
+
"date_modified": "2023-07-11",
|
|
8
|
+
"id": "NL-ABC-123456-0",
|
|
9
|
+
"name": "My Charge Card",
|
|
10
|
+
"uid": "A1B2C3D4E5F6",
|
|
11
|
+
"valid": 1
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"object": "CHARGE_CARDS"
|
|
15
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"data": {
|
|
3
|
+
"chargepoint_type": "HIDDEN",
|
|
4
|
+
"default_card": {
|
|
5
|
+
"customer_name": "Your Name",
|
|
6
|
+
"id": "NL-ABC-123456-0",
|
|
7
|
+
"name": "Your Card",
|
|
8
|
+
"uid": "A1B2C3D4E5F6",
|
|
9
|
+
"valid": 1
|
|
10
|
+
},
|
|
11
|
+
"evse_id": "BCU123456",
|
|
12
|
+
"is_cable": true,
|
|
13
|
+
"led_intensity": {
|
|
14
|
+
"permission": "none",
|
|
15
|
+
"value": 0
|
|
16
|
+
},
|
|
17
|
+
"led_interaction": {
|
|
18
|
+
"permission": "none",
|
|
19
|
+
"value": false
|
|
20
|
+
},
|
|
21
|
+
"model_type": "H:MOVE-C32T2",
|
|
22
|
+
"plug_and_charge": {
|
|
23
|
+
"permission": "write",
|
|
24
|
+
"value": true
|
|
25
|
+
},
|
|
26
|
+
"plug_and_charge_card": {
|
|
27
|
+
"customer_name": "Your Name",
|
|
28
|
+
"id": "NL-ABC-123456-0",
|
|
29
|
+
"name": "Your Card",
|
|
30
|
+
"uid": "A1B2C3D4E5F6",
|
|
31
|
+
"valid": 1
|
|
32
|
+
},
|
|
33
|
+
"plug_and_charge_notification": false,
|
|
34
|
+
"preferred_card": {
|
|
35
|
+
"customer_name": "Your Name",
|
|
36
|
+
"id": "NL-ABC-123456-0",
|
|
37
|
+
"name": "Your Card",
|
|
38
|
+
"uid": "A1B2C3D4E5F6",
|
|
39
|
+
"valid": 1
|
|
40
|
+
},
|
|
41
|
+
"public_charging": {
|
|
42
|
+
"permission": "write",
|
|
43
|
+
"value": false
|
|
44
|
+
},
|
|
45
|
+
"smart_charging": true,
|
|
46
|
+
"smart_charging_dynamic": true
|
|
47
|
+
},
|
|
48
|
+
"evse_id": "BCU123456",
|
|
49
|
+
"object": "CH_SETTINGS"
|
|
50
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"data": [
|
|
3
|
+
{
|
|
4
|
+
"activity": "available",
|
|
5
|
+
"chargepoint_type": "HIDDEN",
|
|
6
|
+
"default_card": {
|
|
7
|
+
"customer_name": "Your Name",
|
|
8
|
+
"id": "NL-ABC-123456-0",
|
|
9
|
+
"name": "Your Card",
|
|
10
|
+
"uid": "A1B2C3D4E5F6",
|
|
11
|
+
"valid": 1
|
|
12
|
+
},
|
|
13
|
+
"delayed_charging": {
|
|
14
|
+
"permission": "none",
|
|
15
|
+
"value": false
|
|
16
|
+
},
|
|
17
|
+
"evse_id": "BCU123456",
|
|
18
|
+
"is_cable": true,
|
|
19
|
+
"led_interaction": {
|
|
20
|
+
"permission": "read",
|
|
21
|
+
"value": false
|
|
22
|
+
},
|
|
23
|
+
"location": {
|
|
24
|
+
"city": "Utrecht",
|
|
25
|
+
"country": "NL",
|
|
26
|
+
"housenumber": "100",
|
|
27
|
+
"street": "Europalaan",
|
|
28
|
+
"x_coord": 50.1234,
|
|
29
|
+
"y_coord": 5.01234,
|
|
30
|
+
"zipcode": "3526KS"
|
|
31
|
+
},
|
|
32
|
+
"model_type": "H:MOVE-C32T2",
|
|
33
|
+
"name": "",
|
|
34
|
+
"plug_and_charge": {
|
|
35
|
+
"permission": "write",
|
|
36
|
+
"value": true
|
|
37
|
+
},
|
|
38
|
+
"plug_and_charge_card": {
|
|
39
|
+
"customer_name": "Your Name",
|
|
40
|
+
"id": "NL-ABC-123456-0",
|
|
41
|
+
"name": "Your Card",
|
|
42
|
+
"uid": "A1B2C3D4E5F6",
|
|
43
|
+
"valid": 1
|
|
44
|
+
},
|
|
45
|
+
"plug_and_charge_notification": false,
|
|
46
|
+
"preferred_card": {
|
|
47
|
+
"customer_name": "Your Name",
|
|
48
|
+
"id": "NL-ABC-123456-0",
|
|
49
|
+
"name": "Your Card",
|
|
50
|
+
"uid": "A1B2C3D4E5F6",
|
|
51
|
+
"valid": 1
|
|
52
|
+
},
|
|
53
|
+
"public_charging": {
|
|
54
|
+
"permission": "write",
|
|
55
|
+
"value": false
|
|
56
|
+
},
|
|
57
|
+
"publish_location": {
|
|
58
|
+
"permission": "write",
|
|
59
|
+
"value": false
|
|
60
|
+
},
|
|
61
|
+
"smart_charging": true,
|
|
62
|
+
"smart_charging_dynamic": true,
|
|
63
|
+
"tariff": {
|
|
64
|
+
"currency": "EUR",
|
|
65
|
+
"price_ex_vat": 0.2,
|
|
66
|
+
"price_in_vat": 0.242,
|
|
67
|
+
"start_price_ex_vat": 0,
|
|
68
|
+
"start_price_in_vat": 0,
|
|
69
|
+
"tariff_id": "NLBCUT58",
|
|
70
|
+
"vat_percentage": 21
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
"object": "CHARGE_POINTS"
|
|
75
|
+
}
|
|
@@ -20,9 +20,12 @@ class TestAuthentication:
|
|
|
20
20
|
async with client_with_auth:
|
|
21
21
|
assert client_with_auth.token is not None
|
|
22
22
|
|
|
23
|
-
async def
|
|
23
|
+
async def test_authentication_rejected_live(self, connected_client: BlueCurrentClient):
|
|
24
|
+
# Guards the rejection *contract* against backend drift: the offline test in test_offline.py
|
|
25
|
+
# scripts our assumption of how rejection looks, so this hits the real backend with bad
|
|
26
|
+
# credentials to confirm the client still raises. connected_client skips it without creds.
|
|
24
27
|
with raises(AuthenticationFailed):
|
|
25
|
-
async with
|
|
28
|
+
async with BlueCurrentClient(username="invalid", password="invalid"):
|
|
26
29
|
pass
|
|
27
30
|
|
|
28
31
|
|
|
@@ -165,3 +168,14 @@ class TestRestApi:
|
|
|
165
168
|
if n_transactions >= 30:
|
|
166
169
|
break
|
|
167
170
|
assert len(unique_transactions) == 30
|
|
171
|
+
|
|
172
|
+
async def test_get_api_token(self, connected_client: BlueCurrentClient):
|
|
173
|
+
token = await connected_client.get_api_token()
|
|
174
|
+
assert isinstance(token, str)
|
|
175
|
+
assert token
|
|
176
|
+
|
|
177
|
+
async def test_api_token_auth(self, connected_client: BlueCurrentClient):
|
|
178
|
+
token = await connected_client.get_api_token()
|
|
179
|
+
async with BlueCurrentClient(api_token=token) as token_client:
|
|
180
|
+
assert isinstance(await token_client.get_charge_points(), list)
|
|
181
|
+
assert token_client.customer_id is not None
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
from asyncio import CancelledError, create_task, gather, sleep
|
|
2
|
+
from contextlib import suppress
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
from json import JSONDecodeError
|
|
5
|
+
|
|
6
|
+
from fake_socket import FAKE_CUSTOMER_ID, FAKE_TOKEN, FakeSocket, load_fixture, make_fake_connect
|
|
7
|
+
from pytest import MonkeyPatch, raises
|
|
8
|
+
|
|
9
|
+
from pybluecurrent import BlueCurrentClient
|
|
10
|
+
from pybluecurrent.exceptions import AuthenticationFailed, BlueCurrentException
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def _expect_auth_failure(monkeypatch: MonkeyPatch, socket: FakeSocket, **client_kwargs) -> None:
|
|
14
|
+
"""Connect a client backed by ``socket`` and assert ``__aenter__`` fails authentication.
|
|
15
|
+
|
|
16
|
+
Cleans up the leaked handler task / httpx client itself, since ``__aexit__`` never runs when
|
|
17
|
+
``__aenter__`` raises (a lifecycle gap tracked separately in #9).
|
|
18
|
+
"""
|
|
19
|
+
monkeypatch.setattr("pybluecurrent.client.connect", make_fake_connect(socket))
|
|
20
|
+
client = BlueCurrentClient(**client_kwargs)
|
|
21
|
+
try:
|
|
22
|
+
with raises(AuthenticationFailed):
|
|
23
|
+
await client.__aenter__()
|
|
24
|
+
finally:
|
|
25
|
+
if client.consumer is not None:
|
|
26
|
+
client.consumer.cancel()
|
|
27
|
+
if client.httpx_client is not None:
|
|
28
|
+
await client.httpx_client.__aexit__(None, None, None)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestOfflineAuth:
|
|
32
|
+
"""Auth handshake against the offline fake — no credentials, no network."""
|
|
33
|
+
|
|
34
|
+
async def test_password_auth_success(self, offline_client: BlueCurrentClient):
|
|
35
|
+
assert offline_client.token == FAKE_TOKEN
|
|
36
|
+
|
|
37
|
+
async def test_password_auth_failed(self, monkeypatch: MonkeyPatch, fake_socket: FakeSocket):
|
|
38
|
+
fake_socket.on("VALIDATE_PASSWORD", {"object": "STATUS_PASSWORD", "accepted": False})
|
|
39
|
+
await _expect_auth_failure(monkeypatch, fake_socket, username="username", password="password")
|
|
40
|
+
|
|
41
|
+
async def test_token_auth_success(self, monkeypatch: MonkeyPatch, fake_socket: FakeSocket):
|
|
42
|
+
monkeypatch.setattr("pybluecurrent.client.connect", make_fake_connect(fake_socket))
|
|
43
|
+
async with BlueCurrentClient(api_token="some-token") as client:
|
|
44
|
+
assert client.token == FAKE_TOKEN
|
|
45
|
+
assert client.customer_id == FAKE_CUSTOMER_ID
|
|
46
|
+
|
|
47
|
+
async def test_token_auth_failed(self, monkeypatch: MonkeyPatch, fake_socket: FakeSocket):
|
|
48
|
+
fake_socket.on("VALIDATE_API_TOKEN", {"object": "STATUS_API_TOKEN", "success": False})
|
|
49
|
+
await _expect_auth_failure(monkeypatch, fake_socket, api_token="bad-token")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestOfflineTransport:
|
|
53
|
+
"""Exercise ``_send`` / ``_receive`` / ``_handler`` directly against the fake."""
|
|
54
|
+
|
|
55
|
+
async def test_send_injects_authorization(self, offline_client: BlueCurrentClient, fake_socket: FakeSocket):
|
|
56
|
+
fake_socket.sent.clear()
|
|
57
|
+
await offline_client._send({"command": "PING"}, token=True)
|
|
58
|
+
assert fake_socket.sent[-1]["Authorization"] == f"Token {FAKE_TOKEN}"
|
|
59
|
+
|
|
60
|
+
async def test_send_without_token(self, offline_client: BlueCurrentClient, fake_socket: FakeSocket):
|
|
61
|
+
fake_socket.sent.clear()
|
|
62
|
+
await offline_client._send({"command": "PING"})
|
|
63
|
+
assert "Authorization" not in fake_socket.sent[-1]
|
|
64
|
+
|
|
65
|
+
async def test_receive_matches_object(self, offline_client: BlueCurrentClient, fake_socket: FakeSocket):
|
|
66
|
+
task = create_task(offline_client._receive("PONG"))
|
|
67
|
+
await sleep(0)
|
|
68
|
+
fake_socket.feed({"object": "PONG", "value": 1})
|
|
69
|
+
assert (await task) == {"object": "PONG", "value": 1}
|
|
70
|
+
|
|
71
|
+
async def test_receive_discards_nonmatching(self, offline_client: BlueCurrentClient, fake_socket: FakeSocket):
|
|
72
|
+
task = create_task(offline_client._receive("WANTED"))
|
|
73
|
+
await sleep(0)
|
|
74
|
+
fake_socket.feed({"object": "NOISE", "value": 0})
|
|
75
|
+
fake_socket.feed({"object": "WANTED", "value": 1})
|
|
76
|
+
assert (await task)["value"] == 1
|
|
77
|
+
|
|
78
|
+
async def test_receive_raises_on_error(self, offline_client: BlueCurrentClient, fake_socket: FakeSocket):
|
|
79
|
+
task = create_task(offline_client._receive("WANTED"))
|
|
80
|
+
await sleep(0)
|
|
81
|
+
fake_socket.feed(load_fixture("error_forbidden"))
|
|
82
|
+
with raises(BlueCurrentException) as exc:
|
|
83
|
+
await task
|
|
84
|
+
assert exc.value.args[0]["message"] == "forbidden"
|
|
85
|
+
|
|
86
|
+
async def test_receive_timeout(self, offline_client: BlueCurrentClient):
|
|
87
|
+
with raises(TimeoutError):
|
|
88
|
+
await offline_client._receive("NEVER_ARRIVES", timeout=0)
|
|
89
|
+
|
|
90
|
+
async def test_handler_fans_out_to_all_waiters(self, offline_client: BlueCurrentClient, fake_socket: FakeSocket):
|
|
91
|
+
# A single frame reaches every concurrent waiter — the broadcast behaviour that #8 hardens.
|
|
92
|
+
first = create_task(offline_client._receive("PING"))
|
|
93
|
+
second = create_task(offline_client._receive("PING"))
|
|
94
|
+
await sleep(0)
|
|
95
|
+
fake_socket.feed({"object": "PING", "value": 1})
|
|
96
|
+
assert (await first)["value"] == 1
|
|
97
|
+
assert (await second)["value"] == 1
|
|
98
|
+
|
|
99
|
+
async def test_non_json_frame_kills_consumer(self, offline_client: BlueCurrentClient, fake_socket: FakeSocket):
|
|
100
|
+
# Documents current behaviour: a malformed frame kills the handler task (a seam #9 will address).
|
|
101
|
+
consumer = offline_client.consumer
|
|
102
|
+
assert consumer is not None
|
|
103
|
+
fake_socket.feed("this is not json")
|
|
104
|
+
for _ in range(5):
|
|
105
|
+
await sleep(0)
|
|
106
|
+
if consumer.done():
|
|
107
|
+
break
|
|
108
|
+
assert consumer.done()
|
|
109
|
+
assert isinstance(consumer.exception(), JSONDecodeError)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestOfflineCommands:
|
|
113
|
+
"""Command round-trips driven by recorded fixtures."""
|
|
114
|
+
|
|
115
|
+
async def test_get_charge_points(self, offline_client: BlueCurrentClient, fake_socket: FakeSocket):
|
|
116
|
+
fake_socket.on("GET_CHARGE_POINTS", load_fixture("charge_points"))
|
|
117
|
+
charge_points = await offline_client.get_charge_points()
|
|
118
|
+
assert charge_points[0]["evse_id"] == "BCU123456"
|
|
119
|
+
|
|
120
|
+
async def test_get_grid_status(self, offline_client: BlueCurrentClient, fake_socket: FakeSocket):
|
|
121
|
+
fake_socket.on("GET_GRID_STATUS", load_fixture("grid_status"))
|
|
122
|
+
status = await offline_client.get_grid_status("BCU123456")
|
|
123
|
+
assert status["id"] == "GRID-BCU123456"
|
|
124
|
+
assert status["grid_actual_p1"] == 1
|
|
125
|
+
|
|
126
|
+
async def test_get_charge_point_settings(self, offline_client: BlueCurrentClient, fake_socket: FakeSocket):
|
|
127
|
+
fake_socket.on("GET_CH_SETTINGS", load_fixture("charge_point_settings"))
|
|
128
|
+
settings = await offline_client.get_charge_point_settings("BCU123456")
|
|
129
|
+
assert settings["evse_id"] == "BCU123456"
|
|
130
|
+
|
|
131
|
+
async def test_get_charge_cards(self, offline_client: BlueCurrentClient, fake_socket: FakeSocket):
|
|
132
|
+
fake_socket.on("GET_CHARGE_CARDS", load_fixture("charge_cards"))
|
|
133
|
+
cards = await offline_client.get_charge_cards()
|
|
134
|
+
assert cards[0]["uid"] == "A1B2C3D4E5F6"
|
|
135
|
+
assert cards[0]["date_created"] == date(2023, 6, 27)
|
|
136
|
+
assert cards[0]["date_became_invalid"] is None
|
|
137
|
+
|
|
138
|
+
async def test_get_account(self, offline_client: BlueCurrentClient, fake_socket: FakeSocket):
|
|
139
|
+
fake_socket.on("GET_ACCOUNT", load_fixture("account"))
|
|
140
|
+
account = await offline_client.get_account()
|
|
141
|
+
assert account["full_name"] == "Your Full Name"
|
|
142
|
+
assert account["first_login_app"] == datetime(2020, 1, 15, 13, 33, 52)
|
|
143
|
+
|
|
144
|
+
async def test_get_sustainability_status(self, offline_client: BlueCurrentClient, fake_socket: FakeSocket):
|
|
145
|
+
fake_socket.on("GET_SUSTAINABILITY_STATUS", load_fixture("sustainability_status"))
|
|
146
|
+
status = await offline_client.get_sustainability_status()
|
|
147
|
+
assert status == {"trees": 1, "co2": 12.345}
|
|
148
|
+
|
|
149
|
+
async def test_error_is_raised(self, offline_client: BlueCurrentClient, fake_socket: FakeSocket):
|
|
150
|
+
fake_socket.on("GET_GRID_STATUS", load_fixture("error_forbidden"))
|
|
151
|
+
with raises(BlueCurrentException) as exc:
|
|
152
|
+
await offline_client.get_grid_status("BOGUS")
|
|
153
|
+
assert exc.value.args[0]["message"] == "forbidden"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TestOfflineConcurrency:
|
|
157
|
+
"""Concurrency safety: same-type serialization (C1) and error attribution (C2)."""
|
|
158
|
+
|
|
159
|
+
async def test_same_type_calls_do_not_cross_consume(
|
|
160
|
+
self, offline_client: BlueCurrentClient, fake_socket: FakeSocket
|
|
161
|
+
):
|
|
162
|
+
# Give each GET_GRID_STATUS a distinct response. Without the per-object lock both waiters
|
|
163
|
+
# (subscribed at once) would grab the first reply; with it, each call gets its own.
|
|
164
|
+
sent = []
|
|
165
|
+
|
|
166
|
+
def responder(msg):
|
|
167
|
+
sent.append(msg)
|
|
168
|
+
return [{"object": "GRID_STATUS", "data": {"id": f"GRID-{len(sent)}"}}]
|
|
169
|
+
|
|
170
|
+
fake_socket.responder["GET_GRID_STATUS"] = responder
|
|
171
|
+
first, second = await gather(
|
|
172
|
+
offline_client.get_grid_status("A"),
|
|
173
|
+
offline_client.get_grid_status("B"),
|
|
174
|
+
)
|
|
175
|
+
assert {first["id"], second["id"]} == {"GRID-1", "GRID-2"}
|
|
176
|
+
|
|
177
|
+
async def test_read_ignores_an_async_commands_error(
|
|
178
|
+
self, offline_client: BlueCurrentClient, fake_socket: FakeSocket
|
|
179
|
+
):
|
|
180
|
+
# A read (flow_id=None) must not be poisoned by an error carrying a flow_id (someone else's).
|
|
181
|
+
task = create_task(offline_client._receive("WANTED"))
|
|
182
|
+
await sleep(0)
|
|
183
|
+
fake_socket.feed({"object": "ERROR", "flow_id": "other", "message": "not mine"})
|
|
184
|
+
fake_socket.feed({"object": "WANTED", "value": 1})
|
|
185
|
+
assert (await task)["value"] == 1
|
|
186
|
+
|
|
187
|
+
async def test_async_command_ignores_another_commands_error(
|
|
188
|
+
self, offline_client: BlueCurrentClient, fake_socket: FakeSocket
|
|
189
|
+
):
|
|
190
|
+
# An async waiter (flow_id="F") ignores an error correlated to a different flow and
|
|
191
|
+
# raises only on the one bearing its own flow_id.
|
|
192
|
+
task = create_task(offline_client._receive("STATUS_X", flow_id="F"))
|
|
193
|
+
await sleep(0)
|
|
194
|
+
fake_socket.feed({"object": "ERROR", "flow_id": "other", "message": "another command"})
|
|
195
|
+
fake_socket.feed({"object": "ERROR", "flow_id": "F", "message": "mine"})
|
|
196
|
+
with raises(BlueCurrentException) as exc:
|
|
197
|
+
await task
|
|
198
|
+
assert exc.value.args[0]["message"] == "mine"
|
|
199
|
+
|
|
200
|
+
async def test_async_command_claims_uncorrelated_error(
|
|
201
|
+
self, offline_client: BlueCurrentClient, fake_socket: FakeSocket
|
|
202
|
+
):
|
|
203
|
+
# Not every error carries a flow_id ("forbidden" has none); the waiting async command
|
|
204
|
+
# still surfaces such an uncorrelated error rather than hanging on it.
|
|
205
|
+
task = create_task(offline_client._receive("STATUS_X", flow_id="F"))
|
|
206
|
+
await sleep(0)
|
|
207
|
+
fake_socket.feed({"object": "ERROR", "message": "forbidden"})
|
|
208
|
+
with raises(BlueCurrentException) as exc:
|
|
209
|
+
await task
|
|
210
|
+
assert exc.value.args[0]["message"] == "forbidden"
|
|
211
|
+
|
|
212
|
+
async def test_different_type_calls_run_concurrently(
|
|
213
|
+
self, offline_client: BlueCurrentClient, fake_socket: FakeSocket
|
|
214
|
+
):
|
|
215
|
+
# A slow two-phase command holds only its own lock: stall soft_reset after its RECEIVED_ ack
|
|
216
|
+
# (no STATUS_ fed) and show a grid read on a different key still completes.
|
|
217
|
+
fake_socket.on("SOFT_RESET", {"object": "RECEIVED_SOFT_RESET"})
|
|
218
|
+
reset = create_task(offline_client.soft_reset("A"))
|
|
219
|
+
await sleep(0)
|
|
220
|
+
fake_socket.on("GET_GRID_STATUS", {"object": "GRID_STATUS", "data": {"id": "GRID-1"}})
|
|
221
|
+
status = await offline_client.get_grid_status("A")
|
|
222
|
+
assert status["id"] == "GRID-1"
|
|
223
|
+
reset.cancel()
|
|
224
|
+
with suppress(CancelledError):
|
|
225
|
+
await reset
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|