voltie 1.0.0__py3-none-any.whl
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.
- voltie/__init__.py +51 -0
- voltie/charger.py +477 -0
- voltie/const.py +109 -0
- voltie/exceptions.py +26 -0
- voltie/models.py +443 -0
- voltie/py.typed +0 -0
- voltie/session.py +73 -0
- voltie-1.0.0.dist-info/METADATA +122 -0
- voltie-1.0.0.dist-info/RECORD +11 -0
- voltie-1.0.0.dist-info/WHEEL +4 -0
- voltie-1.0.0.dist-info/licenses/LICENSE +202 -0
voltie/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Asynchronous Python client for Voltie EV chargers."""
|
|
2
|
+
|
|
3
|
+
from .charger import VoltieCharger
|
|
4
|
+
from .const import (
|
|
5
|
+
DEFAULT_PORT,
|
|
6
|
+
NON_PROBLEM_STATES,
|
|
7
|
+
AccessMode,
|
|
8
|
+
DlmMode,
|
|
9
|
+
EvseState,
|
|
10
|
+
ForceSinglePhase,
|
|
11
|
+
)
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
VoltieAuthenticationError,
|
|
14
|
+
VoltieCommandError,
|
|
15
|
+
VoltieConnectionError,
|
|
16
|
+
VoltieError,
|
|
17
|
+
VoltieUnsupportedError,
|
|
18
|
+
)
|
|
19
|
+
from .models import (
|
|
20
|
+
ChargingSession,
|
|
21
|
+
Config,
|
|
22
|
+
Power,
|
|
23
|
+
RfidStatus,
|
|
24
|
+
RfidTag,
|
|
25
|
+
RfidTagList,
|
|
26
|
+
Status,
|
|
27
|
+
)
|
|
28
|
+
from .session import SessionTracker
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"DEFAULT_PORT",
|
|
32
|
+
"NON_PROBLEM_STATES",
|
|
33
|
+
"AccessMode",
|
|
34
|
+
"ChargingSession",
|
|
35
|
+
"Config",
|
|
36
|
+
"DlmMode",
|
|
37
|
+
"EvseState",
|
|
38
|
+
"ForceSinglePhase",
|
|
39
|
+
"Power",
|
|
40
|
+
"RfidStatus",
|
|
41
|
+
"RfidTag",
|
|
42
|
+
"RfidTagList",
|
|
43
|
+
"SessionTracker",
|
|
44
|
+
"Status",
|
|
45
|
+
"VoltieAuthenticationError",
|
|
46
|
+
"VoltieCharger",
|
|
47
|
+
"VoltieCommandError",
|
|
48
|
+
"VoltieConnectionError",
|
|
49
|
+
"VoltieError",
|
|
50
|
+
"VoltieUnsupportedError",
|
|
51
|
+
]
|
voltie/charger.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Asynchronous client for the Voltie charger's local HTTP API."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from http import HTTPStatus
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any, Self
|
|
11
|
+
|
|
12
|
+
import aiohttp
|
|
13
|
+
from yarl import URL
|
|
14
|
+
|
|
15
|
+
from .const import (
|
|
16
|
+
DEFAULT_PORT,
|
|
17
|
+
DEFAULT_TIMEOUT,
|
|
18
|
+
AccessMode,
|
|
19
|
+
DlmMode,
|
|
20
|
+
Endpoint,
|
|
21
|
+
ForceSinglePhase,
|
|
22
|
+
)
|
|
23
|
+
from .exceptions import (
|
|
24
|
+
VoltieAuthenticationError,
|
|
25
|
+
VoltieCommandError,
|
|
26
|
+
VoltieConnectionError,
|
|
27
|
+
VoltieUnsupportedError,
|
|
28
|
+
)
|
|
29
|
+
from .models import (
|
|
30
|
+
CONFIG_KEYS,
|
|
31
|
+
ChargingSession,
|
|
32
|
+
Config,
|
|
33
|
+
Power,
|
|
34
|
+
RfidStatus,
|
|
35
|
+
RfidTagList,
|
|
36
|
+
Status,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_LOGGER = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
# Error codes of the HTTP API; 0 is success.
|
|
42
|
+
_ERROR_MESSAGES: Mapping[int, str] = {
|
|
43
|
+
1: "command not possible in the current state",
|
|
44
|
+
5: "incorrect message format or parameter",
|
|
45
|
+
23: "not the main unit of its prepaid RFID cluster",
|
|
46
|
+
24: "unknown command",
|
|
47
|
+
}
|
|
48
|
+
# Only this code means the firmware cannot do what was asked. Code 23 comes
|
|
49
|
+
# from current firmware too: the request must go to another unit instead.
|
|
50
|
+
_UNSUPPORTED_ERROR_CODE = 24
|
|
51
|
+
|
|
52
|
+
# Fields some firmware versions leave out of a status response now and then.
|
|
53
|
+
# The last value seen stands in for a missing one, so a single short response
|
|
54
|
+
# does not make the charger look unknown for a whole poll interval.
|
|
55
|
+
_STICKY_STATUS_FIELDS = (
|
|
56
|
+
"evse_state",
|
|
57
|
+
"is_car_connected",
|
|
58
|
+
"is_charging",
|
|
59
|
+
"charge_enabled",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
_COLOR_RE = re.compile(r"#?(?P<hex>[0-9A-Fa-f]{6})")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class VoltieCharger:
|
|
66
|
+
"""A Voltie charger, reached over its local HTTP API."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
host: str,
|
|
71
|
+
*,
|
|
72
|
+
port: int = DEFAULT_PORT,
|
|
73
|
+
username: str | None = None,
|
|
74
|
+
password: str | None = None,
|
|
75
|
+
session: aiohttp.ClientSession | None = None,
|
|
76
|
+
request_timeout: float = DEFAULT_TIMEOUT,
|
|
77
|
+
retries: int = 1,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Initialize the client.
|
|
80
|
+
|
|
81
|
+
Credentials are only needed when HTTP API authentication is enabled in
|
|
82
|
+
the Voltie app. Read requests are retried `retries` times when the
|
|
83
|
+
charger cannot be reached; commands are never retried.
|
|
84
|
+
"""
|
|
85
|
+
self.host = host
|
|
86
|
+
self.port = port
|
|
87
|
+
self._headers: dict[str, str] = {}
|
|
88
|
+
if username and password:
|
|
89
|
+
token = base64.b64encode(f"{username}:{password}".encode()).decode()
|
|
90
|
+
self._headers["Authorization"] = f"Basic {token}"
|
|
91
|
+
self._session = session
|
|
92
|
+
self._close_session = False
|
|
93
|
+
self._timeout = aiohttp.ClientTimeout(total=request_timeout)
|
|
94
|
+
self._retries = retries
|
|
95
|
+
self._last_status: dict[str, Any] = {}
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def url(self) -> URL:
|
|
99
|
+
"""The base URL of the charger's HTTP API."""
|
|
100
|
+
return URL.build(scheme="http", host=self.host, port=self.port)
|
|
101
|
+
|
|
102
|
+
async def _request(
|
|
103
|
+
self,
|
|
104
|
+
method: str,
|
|
105
|
+
endpoint: Endpoint,
|
|
106
|
+
*,
|
|
107
|
+
params: Mapping[str, str] | None = None,
|
|
108
|
+
body: Mapping[str, Any] | None = None,
|
|
109
|
+
) -> dict[str, Any]:
|
|
110
|
+
"""Send a request, retrying reads that fail to connect."""
|
|
111
|
+
attempts = self._retries + 1 if method == "GET" else 1
|
|
112
|
+
for attempt in range(attempts):
|
|
113
|
+
try:
|
|
114
|
+
return await self._request_once(method, endpoint, params, body)
|
|
115
|
+
except VoltieConnectionError as err:
|
|
116
|
+
if attempt + 1 == attempts:
|
|
117
|
+
raise
|
|
118
|
+
_LOGGER.debug("Retrying %s %s after: %s", method, endpoint, err)
|
|
119
|
+
await asyncio.sleep(1)
|
|
120
|
+
raise AssertionError # pragma: no cover
|
|
121
|
+
|
|
122
|
+
async def _request_once(
|
|
123
|
+
self,
|
|
124
|
+
method: str,
|
|
125
|
+
endpoint: Endpoint,
|
|
126
|
+
params: Mapping[str, str] | None,
|
|
127
|
+
body: Mapping[str, Any] | None,
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
if self._session is None:
|
|
130
|
+
self._session = aiohttp.ClientSession()
|
|
131
|
+
self._close_session = True
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
async with self._session.request(
|
|
135
|
+
method,
|
|
136
|
+
self.url / endpoint,
|
|
137
|
+
headers=self._headers,
|
|
138
|
+
params=params,
|
|
139
|
+
json=body,
|
|
140
|
+
timeout=self._timeout,
|
|
141
|
+
) as response:
|
|
142
|
+
if response.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
|
|
143
|
+
raise VoltieAuthenticationError(
|
|
144
|
+
"The charger rejected the HTTP API credentials"
|
|
145
|
+
)
|
|
146
|
+
if response.status in (
|
|
147
|
+
HTTPStatus.NOT_FOUND,
|
|
148
|
+
HTTPStatus.METHOD_NOT_ALLOWED,
|
|
149
|
+
):
|
|
150
|
+
raise VoltieUnsupportedError(
|
|
151
|
+
f"The charger's firmware has no /{endpoint} endpoint"
|
|
152
|
+
)
|
|
153
|
+
if response.status >= HTTPStatus.BAD_REQUEST:
|
|
154
|
+
raise VoltieConnectionError(
|
|
155
|
+
f"The charger answered /{endpoint} with HTTP {response.status}"
|
|
156
|
+
)
|
|
157
|
+
raw = await response.read()
|
|
158
|
+
except TimeoutError as err:
|
|
159
|
+
raise VoltieConnectionError(
|
|
160
|
+
f"Timed out waiting for the charger to answer /{endpoint}"
|
|
161
|
+
) from err
|
|
162
|
+
except aiohttp.ClientError as err:
|
|
163
|
+
raise VoltieConnectionError(
|
|
164
|
+
f"Could not talk to the charger: {err}"
|
|
165
|
+
) from err
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
payload = json.loads(raw) if raw else {}
|
|
169
|
+
except ValueError as err:
|
|
170
|
+
raise VoltieConnectionError(
|
|
171
|
+
f"The charger answered /{endpoint} with something other than JSON"
|
|
172
|
+
) from err
|
|
173
|
+
if not isinstance(payload, dict):
|
|
174
|
+
raise VoltieConnectionError(
|
|
175
|
+
f"The charger answered /{endpoint} with an unexpected response"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
_raise_for_error_code(endpoint, payload)
|
|
179
|
+
return payload
|
|
180
|
+
|
|
181
|
+
async def status(self) -> Status:
|
|
182
|
+
"""Return the charger's live status."""
|
|
183
|
+
payload = await self._request("GET", Endpoint.STATUS)
|
|
184
|
+
if not payload.get("charger_id"):
|
|
185
|
+
raise VoltieConnectionError("The charger did not report its ID")
|
|
186
|
+
for key in _STICKY_STATUS_FIELDS:
|
|
187
|
+
if payload.get(key) is None and key in self._last_status:
|
|
188
|
+
payload[key] = self._last_status[key]
|
|
189
|
+
self._last_status = {
|
|
190
|
+
key: payload[key]
|
|
191
|
+
for key in _STICKY_STATUS_FIELDS
|
|
192
|
+
if payload.get(key) is not None
|
|
193
|
+
}
|
|
194
|
+
return Status.from_dict(payload)
|
|
195
|
+
|
|
196
|
+
async def power(self) -> Power:
|
|
197
|
+
"""Return the per-phase measurements."""
|
|
198
|
+
return Power.from_dict(await self._request("GET", Endpoint.POWER))
|
|
199
|
+
|
|
200
|
+
async def config(self) -> Config:
|
|
201
|
+
"""Return the charger's settings."""
|
|
202
|
+
return Config.from_dict(await self._request("GET", Endpoint.CONFIG))
|
|
203
|
+
|
|
204
|
+
async def set_config(
|
|
205
|
+
self,
|
|
206
|
+
*,
|
|
207
|
+
autostart_enabled: bool | None = None,
|
|
208
|
+
current_limit: int | None = None,
|
|
209
|
+
display_enabled: bool | None = None,
|
|
210
|
+
front_led_enabled: bool | None = None,
|
|
211
|
+
rear_led_enabled: bool | None = None,
|
|
212
|
+
buzzer_enabled: bool | None = None,
|
|
213
|
+
force_single_phase: ForceSinglePhase | None = None,
|
|
214
|
+
dlm_mode: DlmMode | None = None,
|
|
215
|
+
dlm_current_limit: int | None = None,
|
|
216
|
+
dlm_eco_start_current: int | None = None,
|
|
217
|
+
grid_stop_voltage: int | None = None,
|
|
218
|
+
grid_min_voltage: int | None = None,
|
|
219
|
+
grid_max_voltage: int | None = None,
|
|
220
|
+
grid_increase_delay: int | None = None,
|
|
221
|
+
grid_decrease_delay: int | None = None,
|
|
222
|
+
out_of_service: bool | None = None,
|
|
223
|
+
access_mode: AccessMode | None = None,
|
|
224
|
+
) -> None:
|
|
225
|
+
"""Change settings; the ones left at None stay as they are.
|
|
226
|
+
|
|
227
|
+
The charger drops a value it does not accept (out of range, not
|
|
228
|
+
supported by the hardware, or not changeable in the current state) and
|
|
229
|
+
only reports how many it took, so a shortfall raises.
|
|
230
|
+
"""
|
|
231
|
+
values = {
|
|
232
|
+
"autostart_enabled": autostart_enabled,
|
|
233
|
+
"current_limit": current_limit,
|
|
234
|
+
"display_enabled": display_enabled,
|
|
235
|
+
"front_led_enabled": front_led_enabled,
|
|
236
|
+
"rear_led_enabled": rear_led_enabled,
|
|
237
|
+
"buzzer_enabled": buzzer_enabled,
|
|
238
|
+
"force_single_phase": force_single_phase,
|
|
239
|
+
"dlm_mode": dlm_mode,
|
|
240
|
+
"dlm_current_limit": dlm_current_limit,
|
|
241
|
+
"dlm_eco_start_current": dlm_eco_start_current,
|
|
242
|
+
"grid_stop_voltage": grid_stop_voltage,
|
|
243
|
+
"grid_min_voltage": grid_min_voltage,
|
|
244
|
+
"grid_max_voltage": grid_max_voltage,
|
|
245
|
+
"grid_increase_delay": grid_increase_delay,
|
|
246
|
+
"grid_decrease_delay": grid_decrease_delay,
|
|
247
|
+
"out_of_service": out_of_service,
|
|
248
|
+
"access_mode": access_mode,
|
|
249
|
+
}
|
|
250
|
+
body = {
|
|
251
|
+
CONFIG_KEYS[name]: int(value) if not isinstance(value, bool) else value
|
|
252
|
+
for name, value in values.items()
|
|
253
|
+
if value is not None
|
|
254
|
+
}
|
|
255
|
+
if not body:
|
|
256
|
+
raise ValueError("No setting to change")
|
|
257
|
+
result = await self._request("PUT", Endpoint.CONFIG, body=body)
|
|
258
|
+
accepted = result.get("accepted")
|
|
259
|
+
if isinstance(accepted, int) and accepted < len(body):
|
|
260
|
+
raise VoltieCommandError(
|
|
261
|
+
f"The charger refused {len(body) - accepted} of {len(body)} settings,"
|
|
262
|
+
" a value outside the range its firmware allows or a setting it"
|
|
263
|
+
" cannot change at the moment"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
async def start(
|
|
267
|
+
self, *, id_tag: str | None = None, name: str | None = None
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Allow charging, optionally recording a tag and name in the session."""
|
|
270
|
+
params = {
|
|
271
|
+
key: value for key, value in (("id_tag", id_tag), ("name", name)) if value
|
|
272
|
+
}
|
|
273
|
+
await self._request("GET", Endpoint.START, params=params or None)
|
|
274
|
+
|
|
275
|
+
async def stop(self) -> None:
|
|
276
|
+
"""Stop charging."""
|
|
277
|
+
await self._request("GET", Endpoint.STOP)
|
|
278
|
+
|
|
279
|
+
async def session(self, cdr_id: int) -> ChargingSession | None:
|
|
280
|
+
"""Return a stored session record, or None if the charger has none."""
|
|
281
|
+
payload = await self._request(
|
|
282
|
+
"GET", Endpoint.CDR, params={"cdr_id": str(cdr_id)}
|
|
283
|
+
)
|
|
284
|
+
return ChargingSession.from_dict(payload.get("cdr"))
|
|
285
|
+
|
|
286
|
+
async def api_version(self) -> int | None:
|
|
287
|
+
"""Return the major HTTP API version, or None if it is not reported.
|
|
288
|
+
|
|
289
|
+
Firmware from before the version endpoint existed reports none.
|
|
290
|
+
"""
|
|
291
|
+
try:
|
|
292
|
+
payload = await self._request("GET", Endpoint.API_VERSION)
|
|
293
|
+
except VoltieUnsupportedError:
|
|
294
|
+
return None
|
|
295
|
+
value = payload.get("api_files_version")
|
|
296
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
297
|
+
return None
|
|
298
|
+
return int(value)
|
|
299
|
+
|
|
300
|
+
async def _command(
|
|
301
|
+
self,
|
|
302
|
+
endpoint: Endpoint,
|
|
303
|
+
command: str,
|
|
304
|
+
params: Mapping[str, Any] | None = None,
|
|
305
|
+
) -> None:
|
|
306
|
+
body: dict[str, Any] = {"command": command}
|
|
307
|
+
if params:
|
|
308
|
+
body["params"] = dict(params)
|
|
309
|
+
await self._request("POST", endpoint, body=body)
|
|
310
|
+
|
|
311
|
+
async def display_text(
|
|
312
|
+
self,
|
|
313
|
+
message: str,
|
|
314
|
+
*,
|
|
315
|
+
repeat_count: int | None = None,
|
|
316
|
+
clear_first: bool | None = None,
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Scroll a message (1 to 100 characters) across the display."""
|
|
319
|
+
params: dict[str, Any] = {"message": message}
|
|
320
|
+
if repeat_count is not None:
|
|
321
|
+
params["repeat_count"] = repeat_count
|
|
322
|
+
if clear_first is not None:
|
|
323
|
+
params["clear_first"] = clear_first
|
|
324
|
+
await self._command(Endpoint.EXTRAS, "display_scroll_text", params)
|
|
325
|
+
|
|
326
|
+
async def set_rear_led(
|
|
327
|
+
self,
|
|
328
|
+
*,
|
|
329
|
+
brightness: float,
|
|
330
|
+
color: str,
|
|
331
|
+
duration: int | None = None,
|
|
332
|
+
) -> None:
|
|
333
|
+
"""Set the rear LED for `duration` seconds (at most an hour).
|
|
334
|
+
|
|
335
|
+
The color is a hex value such as #FF8800; brightness runs from 0 to 1.
|
|
336
|
+
"""
|
|
337
|
+
if (match := _COLOR_RE.fullmatch(color.strip())) is None:
|
|
338
|
+
raise ValueError(f"{color!r} is not a hex RGB color")
|
|
339
|
+
params: dict[str, Any] = {
|
|
340
|
+
"brightness": brightness,
|
|
341
|
+
"color_rgb": f"#{match['hex'].upper()}",
|
|
342
|
+
}
|
|
343
|
+
if duration is not None:
|
|
344
|
+
params["duration_sec"] = duration
|
|
345
|
+
await self._command(Endpoint.EXTRAS, "rear_led_set", params)
|
|
346
|
+
|
|
347
|
+
async def reboot(self) -> None:
|
|
348
|
+
"""Restart the charger; it drops off the network for a few seconds."""
|
|
349
|
+
await self._command(Endpoint.EXTRAS, "charger_reboot")
|
|
350
|
+
|
|
351
|
+
async def rfid_status(self) -> RfidStatus:
|
|
352
|
+
"""Return the RFID reader state; needs HTTP API version 5."""
|
|
353
|
+
payload = await self._request("GET", Endpoint.RFID_STATUS)
|
|
354
|
+
status = payload.get("rfid_status")
|
|
355
|
+
if not isinstance(status, Mapping):
|
|
356
|
+
raise VoltieConnectionError("The charger did not report its RFID status")
|
|
357
|
+
return RfidStatus.from_dict(status)
|
|
358
|
+
|
|
359
|
+
async def rfid_tags(
|
|
360
|
+
self, *, first_item: int | None = None, max_count: int | None = None
|
|
361
|
+
) -> RfidTagList:
|
|
362
|
+
"""Return stored RFID tags; max_count 0 returns all of them."""
|
|
363
|
+
params = {
|
|
364
|
+
key: str(value)
|
|
365
|
+
for key, value in (("first_item", first_item), ("max_count", max_count))
|
|
366
|
+
if value is not None
|
|
367
|
+
}
|
|
368
|
+
return RfidTagList.from_dict(
|
|
369
|
+
await self._request("GET", Endpoint.RFID, params=params or None)
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
async def add_rfid_tag(
|
|
373
|
+
self,
|
|
374
|
+
tag_id: str,
|
|
375
|
+
*,
|
|
376
|
+
name: str | None = None,
|
|
377
|
+
enabled: bool | None = None,
|
|
378
|
+
comment: str | None = None,
|
|
379
|
+
) -> None:
|
|
380
|
+
"""Store a new RFID tag; the ID is 8 to 20 hexadecimal characters."""
|
|
381
|
+
await self._request(
|
|
382
|
+
"POST",
|
|
383
|
+
Endpoint.RFID,
|
|
384
|
+
body=_rfid_body(tag_id, name=name, enabled=enabled, comment=comment),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
async def modify_rfid_tag(
|
|
388
|
+
self,
|
|
389
|
+
tag_id: str,
|
|
390
|
+
*,
|
|
391
|
+
name: str | None = None,
|
|
392
|
+
enabled: bool | None = None,
|
|
393
|
+
comment: str | None = None,
|
|
394
|
+
) -> None:
|
|
395
|
+
"""Change a stored RFID tag; fields left at None stay as they are."""
|
|
396
|
+
await self._request(
|
|
397
|
+
"PUT",
|
|
398
|
+
Endpoint.RFID,
|
|
399
|
+
body=_rfid_body(tag_id, name=name, enabled=enabled, comment=comment),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
async def delete_rfid_tag(self, tag_id: str) -> None:
|
|
403
|
+
"""Remove a stored RFID tag."""
|
|
404
|
+
await self._request("DELETE", Endpoint.RFID, params={"id": tag_id})
|
|
405
|
+
|
|
406
|
+
async def rfid_learn(
|
|
407
|
+
self, *, duration: int | None = None, count_max: int | None = None
|
|
408
|
+
) -> None:
|
|
409
|
+
"""Store the tags presented to the reader for `duration` seconds.
|
|
410
|
+
|
|
411
|
+
Without arguments the firmware defaults apply: 30 seconds, no limit on
|
|
412
|
+
the number of tags.
|
|
413
|
+
"""
|
|
414
|
+
params = {
|
|
415
|
+
key: value
|
|
416
|
+
for key, value in (("timeout_sec", duration), ("count_max", count_max))
|
|
417
|
+
if value is not None
|
|
418
|
+
}
|
|
419
|
+
await self._command(Endpoint.RFID_EXTRAS, "rfid_learn", params)
|
|
420
|
+
|
|
421
|
+
async def rfid_learn_cancel(self) -> None:
|
|
422
|
+
"""End RFID learn mode."""
|
|
423
|
+
await self._command(Endpoint.RFID_EXTRAS, "rfid_learn_cancel")
|
|
424
|
+
|
|
425
|
+
async def close(self) -> None:
|
|
426
|
+
"""Close the HTTP session, if this client opened it."""
|
|
427
|
+
if self._session is not None and self._close_session:
|
|
428
|
+
await self._session.close()
|
|
429
|
+
|
|
430
|
+
async def __aenter__(self) -> Self:
|
|
431
|
+
"""Enter the async context."""
|
|
432
|
+
return self
|
|
433
|
+
|
|
434
|
+
async def __aexit__(self, *_exc_info: object) -> None:
|
|
435
|
+
"""Leave the async context, closing the session this client opened."""
|
|
436
|
+
await self.close()
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _raise_for_error_code(endpoint: Endpoint, payload: Mapping[str, Any]) -> None:
|
|
440
|
+
raw_code = payload.get("error_code")
|
|
441
|
+
if raw_code is None:
|
|
442
|
+
# Some failures answer with a status text instead of an error code.
|
|
443
|
+
if isinstance(status := payload.get("status"), str) and status:
|
|
444
|
+
raise VoltieConnectionError(
|
|
445
|
+
f"The charger could not answer /{endpoint}: {status}"
|
|
446
|
+
)
|
|
447
|
+
return
|
|
448
|
+
try:
|
|
449
|
+
code = int(raw_code)
|
|
450
|
+
except (TypeError, ValueError):
|
|
451
|
+
code = None
|
|
452
|
+
if code is None or isinstance(raw_code, bool):
|
|
453
|
+
raise VoltieConnectionError(
|
|
454
|
+
f"The charger answered /{endpoint} with error code {raw_code!r}"
|
|
455
|
+
)
|
|
456
|
+
if code == 0:
|
|
457
|
+
return
|
|
458
|
+
message = f"The charger refused /{endpoint}: " + _ERROR_MESSAGES.get(
|
|
459
|
+
code, f"error code {code}"
|
|
460
|
+
)
|
|
461
|
+
if code == _UNSUPPORTED_ERROR_CODE:
|
|
462
|
+
raise VoltieUnsupportedError(message, code=code)
|
|
463
|
+
raise VoltieCommandError(message, code=code)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _rfid_body(
|
|
467
|
+
tag_id: str,
|
|
468
|
+
*,
|
|
469
|
+
name: str | None,
|
|
470
|
+
enabled: bool | None,
|
|
471
|
+
comment: str | None,
|
|
472
|
+
) -> dict[str, Any]:
|
|
473
|
+
# The charger reads an absent field as "leave unchanged".
|
|
474
|
+
fields = {"name": name, "enabled": enabled, "comment": comment}
|
|
475
|
+
return {"id": tag_id} | {
|
|
476
|
+
key: value for key, value in fields.items() if value is not None
|
|
477
|
+
}
|
voltie/const.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Constants and enumerations of the Voltie charger HTTP API."""
|
|
2
|
+
|
|
3
|
+
from enum import IntEnum, StrEnum
|
|
4
|
+
|
|
5
|
+
DEFAULT_PORT = 5059
|
|
6
|
+
DEFAULT_TIMEOUT = 10.0
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EvseState(IntEnum):
|
|
10
|
+
"""State of the charger's EVSE (supply equipment) controller.
|
|
11
|
+
|
|
12
|
+
The values follow the firmware's internal state enum, which is more detailed
|
|
13
|
+
than the list in the HTTP API specification.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
UNKNOWN = 0
|
|
17
|
+
EV_NOT_CONNECTED = 1
|
|
18
|
+
EV_CONNECTED_NOT_CHARGING = 2
|
|
19
|
+
EV_CONNECTED_CHARGING = 3
|
|
20
|
+
EV_CONNECTED_CHARGING_VENTILATION = 4
|
|
21
|
+
DIODE_CHECK_FAILED = 5
|
|
22
|
+
GFCI_FAULT = 6
|
|
23
|
+
NO_GROUND = 7
|
|
24
|
+
STUCK_RELAY = 8
|
|
25
|
+
GFI_SELF_TEST_FAILURE = 9
|
|
26
|
+
OVER_TEMPERATURE = 10
|
|
27
|
+
OVER_CURRENT = 11
|
|
28
|
+
I2C_BUS_ERROR = 12
|
|
29
|
+
EV_FAULT = 13
|
|
30
|
+
OVER_HUMIDITY = 14
|
|
31
|
+
PHASE_MISCONNECTED = 15
|
|
32
|
+
OVERVOLTAGE = 16
|
|
33
|
+
UNDERVOLTAGE = 17
|
|
34
|
+
DISABLED = 18
|
|
35
|
+
BOOTING = 19
|
|
36
|
+
MID_METER_MISSING = 20
|
|
37
|
+
POWER_BOARD_UNKNOWN = 21
|
|
38
|
+
SLEEPING = 22 # An old power-saving state that the firmware no longer uses
|
|
39
|
+
SHUTDOWN = 23
|
|
40
|
+
UNDETERMINED = 24
|
|
41
|
+
FIRMWARE_UPDATE = 25
|
|
42
|
+
PE_N_FAULT = 26
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Every state not listed here is a fault, including codes that newer firmware
|
|
46
|
+
# may add. A vehicle fault counts as a problem too, since charging is stuck
|
|
47
|
+
# either way. UNKNOWN is what the charger reports before its first valid EVSE
|
|
48
|
+
# reading, so counting it would raise a false alarm on every start.
|
|
49
|
+
NON_PROBLEM_STATES = frozenset(
|
|
50
|
+
{
|
|
51
|
+
EvseState.UNKNOWN,
|
|
52
|
+
EvseState.EV_NOT_CONNECTED,
|
|
53
|
+
EvseState.EV_CONNECTED_NOT_CHARGING,
|
|
54
|
+
EvseState.EV_CONNECTED_CHARGING,
|
|
55
|
+
EvseState.EV_CONNECTED_CHARGING_VENTILATION,
|
|
56
|
+
EvseState.DISABLED,
|
|
57
|
+
EvseState.BOOTING,
|
|
58
|
+
EvseState.SLEEPING,
|
|
59
|
+
EvseState.UNDETERMINED,
|
|
60
|
+
EvseState.FIRMWARE_UPDATE,
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DlmMode(IntEnum):
|
|
66
|
+
"""Dynamic load management mode."""
|
|
67
|
+
|
|
68
|
+
OFF = 0
|
|
69
|
+
DYNAMIC = 1
|
|
70
|
+
ECO = 2
|
|
71
|
+
GREEN = 3
|
|
72
|
+
GRID_CONTROL = 4
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AccessMode(IntEnum):
|
|
76
|
+
"""Who may start a charging session."""
|
|
77
|
+
|
|
78
|
+
HOME_CHARGER = 0
|
|
79
|
+
HOME_CHARGER_RFID = 1
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ForceSinglePhase(IntEnum):
|
|
83
|
+
"""Single-phase charging setting.
|
|
84
|
+
|
|
85
|
+
Only OFF and ON can be written. The other two values are reported by the
|
|
86
|
+
charger when its hardware cannot switch phases or has not determined yet
|
|
87
|
+
whether it can.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
OFF = 0
|
|
91
|
+
ON = 1
|
|
92
|
+
NOT_SUPPORTED = 2
|
|
93
|
+
UNDETERMINED = 3
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Endpoint(StrEnum):
|
|
97
|
+
"""HTTP API endpoints."""
|
|
98
|
+
|
|
99
|
+
API_VERSION = "apiver"
|
|
100
|
+
CDR = "cdr"
|
|
101
|
+
CONFIG = "config"
|
|
102
|
+
EXTRAS = "extras"
|
|
103
|
+
POWER = "power"
|
|
104
|
+
RFID = "rfid"
|
|
105
|
+
RFID_EXTRAS = "rfid/extras"
|
|
106
|
+
RFID_STATUS = "rfid/status"
|
|
107
|
+
START = "start"
|
|
108
|
+
STATUS = "status"
|
|
109
|
+
STOP = "stop"
|
voltie/exceptions.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Exceptions raised by the Voltie charger client."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class VoltieError(Exception):
|
|
5
|
+
"""Base exception for everything this library raises."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class VoltieConnectionError(VoltieError):
|
|
9
|
+
"""The charger could not be reached or sent an unusable response."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VoltieAuthenticationError(VoltieError):
|
|
13
|
+
"""The charger rejected the HTTP API credentials."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class VoltieCommandError(VoltieError):
|
|
17
|
+
"""The charger understood the request but refused it."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, *, code: int | None = None) -> None:
|
|
20
|
+
"""Initialize the error with the charger's error code, if it sent one."""
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.code = code
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class VoltieUnsupportedError(VoltieCommandError):
|
|
26
|
+
"""The charger's firmware does not implement the endpoint or command."""
|