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 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."""