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.
Files changed (36) hide show
  1. pybluecurrent-0.2.0/CHANGELOG.md +40 -0
  2. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/PKG-INFO +30 -2
  3. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/README.md +29 -1
  4. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent/_version.py +3 -3
  5. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent/client.py +121 -50
  6. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent.egg-info/PKG-INFO +30 -2
  7. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent.egg-info/SOURCES.txt +11 -1
  8. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent.egg-info/scm_file_list.json +10 -0
  9. pybluecurrent-0.2.0/src/pybluecurrent.egg-info/scm_version.json +8 -0
  10. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/tests/conftest.py +16 -1
  11. pybluecurrent-0.2.0/tests/fake_socket.py +130 -0
  12. pybluecurrent-0.2.0/tests/fixtures/account.json +12 -0
  13. pybluecurrent-0.2.0/tests/fixtures/charge_cards.json +15 -0
  14. pybluecurrent-0.2.0/tests/fixtures/charge_point_settings.json +50 -0
  15. pybluecurrent-0.2.0/tests/fixtures/charge_points.json +75 -0
  16. pybluecurrent-0.2.0/tests/fixtures/error_forbidden.json +5 -0
  17. pybluecurrent-0.2.0/tests/fixtures/grid_status.json +11 -0
  18. pybluecurrent-0.2.0/tests/fixtures/sustainability_status.json +5 -0
  19. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/tests/test_client.py +16 -2
  20. pybluecurrent-0.2.0/tests/test_offline.py +225 -0
  21. pybluecurrent-0.1.1/src/pybluecurrent.egg-info/scm_version.json +0 -8
  22. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/.github/workflows/publish.yaml +0 -0
  23. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/.github/workflows/test.yaml +0 -0
  24. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/.gitignore +0 -0
  25. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/.pre-commit-config.yaml +0 -0
  26. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/LICENSE +0 -0
  27. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/pyproject.toml +0 -0
  28. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/setup.cfg +0 -0
  29. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent/__init__.py +0 -0
  30. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent/exceptions.py +0 -0
  31. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent/py.typed +0 -0
  32. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent/utilities.py +0 -0
  33. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent.egg-info/dependency_links.txt +0 -0
  34. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent.egg-info/requires.txt +0 -0
  35. {pybluecurrent-0.1.1 → pybluecurrent-0.2.0}/src/pybluecurrent.egg-info/top_level.txt +0 -0
  36. {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.1.1
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": date(2020, 1, 1),
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": date(2020, 1, 1),
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.1.1'
22
- __version_tuple__ = version_tuple = (0, 1, 1)
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g10ca35774'
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://bo.bluecurrent.nl/app/bc_api/api/v2.0"
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._send(dict(command="GET_ACCOUNT"), token=True)
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 Cards",
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._send(dict(command="GET_CHARGE_CARDS"), token=True)
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._send(dict(command="GET_CHARGE_POINTS"), token=True)
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._send(dict(command="GET_CH_SETTINGS", evse_id=evse_id), token=True)
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._send(dict(command="GET_GRID_STATUS", evse_id=evse_id), token=True)
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._send(dict(command="GET_SESSIONS"), token=True)
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._send(dict(command="GET_SUSTAINABILITY_STATUS"), token=True)
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._send(
261
+ result = await self._request(
228
262
  dict(command="SET_PLUG_AND_CHARGE_CHARGE_CARD", evse_id=evse_id, token_uid=token_uid),
229
- token=True,
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
- await self._send(dict(command="SET_OPERATIVE", evse_id=evse_id, flow_id=str(uuid4())), token=True)
245
- await self._receive("RECEIVED_SET_OPERATIVE")
246
- await self._receive("STATUS_SET_OPERATIVE", timeout=30)
247
- else:
248
- await self._send(dict(command="SET_INOPERATIVE", evse_id=evse_id, flow_id=str(uuid4())), token=True)
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
- # TODO: test
254
- await self._send(
255
- dict(
256
- command="UNLOCK_CONNECTOR",
257
- evse_id=evse_id,
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
- # TODO: verify flow id
266
- await self._send(dict(command="SOFT_RESET", evse_id=evse_id, flow_id=str(uuid4())), token=True)
267
- await self._receive("RECEIVED_SOFT_RESET")
268
- return await self._receive("STATUS_SOFT_RESET", timeout=30)
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(self.credentials[1].encode("utf-8"), self.psk).items()
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
- message = await wait_for(q.get(), timeout=timeout)
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
- raise BlueCurrentException(message)
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.1.1
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": date(2020, 1, 1),
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/test_utilities.py
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
  ]
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.2.0",
3
+ "distance": 0,
4
+ "node": "ge8d24aba3c4114cbd37656dde2ddb1c7a03bdc79",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-07-11"
8
+ }
@@ -1,7 +1,8 @@
1
1
  from os import environ
2
2
  from typing import AsyncGenerator
3
3
 
4
- from pytest import fixture, skip
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
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "error": 2,
3
+ "message": "forbidden",
4
+ "object": "ERROR"
5
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "data": {
3
+ "grid_actual_p1": 1,
4
+ "grid_actual_p2": 2,
5
+ "grid_actual_p3": 3,
6
+ "grid_max_install": 25,
7
+ "grid_max_reserved": 25,
8
+ "id": "GRID-BCU123456"
9
+ },
10
+ "object": "GRID_STATUS"
11
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "co2": 12.345,
3
+ "object": "SUSTAINABILITY_STATUS",
4
+ "trees": 1
5
+ }
@@ -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 test_authentication_failed(self, client: BlueCurrentClient):
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 client:
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
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.1.1",
3
- "distance": 0,
4
- "node": "g10ca3577491631fafa5e8030701eff3c4854f9b4",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-07-04"
8
- }
File without changes
File without changes
File without changes