pyiont 0.1.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.
- pyiont/__init__.py +48 -0
- pyiont/charger.py +260 -0
- pyiont/components.py +194 -0
- pyiont/const.py +137 -0
- pyiont/exceptions.py +26 -0
- pyiont/py.typed +0 -0
- pyiont-0.1.0.dist-info/METADATA +133 -0
- pyiont-0.1.0.dist-info/RECORD +10 -0
- pyiont-0.1.0.dist-info/WHEEL +4 -0
- pyiont-0.1.0.dist-info/licenses/LICENSE +21 -0
pyiont/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Asynchronous Python client for IONT EV chargers over Modbus TCP."""
|
|
2
|
+
|
|
3
|
+
from .charger import (
|
|
4
|
+
SUBSYSTEM_DEVICE,
|
|
5
|
+
SUBSYSTEM_SETTINGS,
|
|
6
|
+
IontCharger,
|
|
7
|
+
UpdateReport,
|
|
8
|
+
connector_subsystem,
|
|
9
|
+
)
|
|
10
|
+
from .components import CommandResult, Connector, Device, Settings
|
|
11
|
+
from .const import (
|
|
12
|
+
DEFAULT_PORT,
|
|
13
|
+
EXTERNAL_POWER_LIMIT_MAX,
|
|
14
|
+
MAX_CONNECTORS,
|
|
15
|
+
AuthorizedBy,
|
|
16
|
+
ChargingState,
|
|
17
|
+
ChargingStrategy,
|
|
18
|
+
ConnectionState,
|
|
19
|
+
CurrentFlow,
|
|
20
|
+
DeviceStatus,
|
|
21
|
+
VehicleState,
|
|
22
|
+
)
|
|
23
|
+
from .exceptions import IontCommandError, IontConnectionError, IontError
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"DEFAULT_PORT",
|
|
27
|
+
"EXTERNAL_POWER_LIMIT_MAX",
|
|
28
|
+
"MAX_CONNECTORS",
|
|
29
|
+
"SUBSYSTEM_DEVICE",
|
|
30
|
+
"SUBSYSTEM_SETTINGS",
|
|
31
|
+
"AuthorizedBy",
|
|
32
|
+
"ChargingState",
|
|
33
|
+
"ChargingStrategy",
|
|
34
|
+
"CommandResult",
|
|
35
|
+
"ConnectionState",
|
|
36
|
+
"Connector",
|
|
37
|
+
"CurrentFlow",
|
|
38
|
+
"Device",
|
|
39
|
+
"DeviceStatus",
|
|
40
|
+
"IontCharger",
|
|
41
|
+
"IontCommandError",
|
|
42
|
+
"IontConnectionError",
|
|
43
|
+
"IontError",
|
|
44
|
+
"Settings",
|
|
45
|
+
"UpdateReport",
|
|
46
|
+
"VehicleState",
|
|
47
|
+
"connector_subsystem",
|
|
48
|
+
]
|
pyiont/charger.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""The IONT charger device object."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from modbus_connection import ModbusConnectionError, ModbusError, ModbusTimeoutError
|
|
10
|
+
|
|
11
|
+
from .components import CommandResult, Connector, Device, Settings
|
|
12
|
+
from .const import (
|
|
13
|
+
AUTHORIZE_BOOST_BY_CONNECTOR_ID_ADDRESS,
|
|
14
|
+
AUTHORIZE_BY_CONNECTOR_ID_ADDRESS,
|
|
15
|
+
COMMAND_POLL_INTERVAL,
|
|
16
|
+
COMMAND_RESULT_OK,
|
|
17
|
+
COMMAND_TIMEOUT,
|
|
18
|
+
CONNECTOR_AUTHORIZE_OFFSET,
|
|
19
|
+
CONNECTOR_BASE,
|
|
20
|
+
CONNECTOR_DEAUTHORIZE_OFFSET,
|
|
21
|
+
CONNECTOR_STRIDE,
|
|
22
|
+
DEAUTHORIZE_BY_CONNECTOR_ID_ADDRESS,
|
|
23
|
+
EXTERNAL_POWER_LIMIT_MAX,
|
|
24
|
+
MAX_CONNECTORS,
|
|
25
|
+
)
|
|
26
|
+
from .exceptions import IontCommandError, IontConnectionError, IontError
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Iterator
|
|
30
|
+
|
|
31
|
+
from modbus_connection import ModbusUnit
|
|
32
|
+
from modbus_connection.model import Component
|
|
33
|
+
|
|
34
|
+
SUBSYSTEM_DEVICE = "device"
|
|
35
|
+
SUBSYSTEM_SETTINGS = "settings"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def connector_subsystem(number: int) -> str:
|
|
39
|
+
"""Return the sub-system name of the connector with a 1-based number."""
|
|
40
|
+
return f"connector_{number}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class UpdateReport:
|
|
45
|
+
"""What one poll refreshed, named by sub-system.
|
|
46
|
+
|
|
47
|
+
A sub-system in ``failed`` kept the values it had. A poll that refreshed
|
|
48
|
+
nothing raises :class:`IontConnectionError` instead of reporting total
|
|
49
|
+
silence here.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
updated: set[str]
|
|
53
|
+
failed: dict[str, IontError]
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def complete(self) -> bool:
|
|
57
|
+
"""Whether every sub-system refreshed."""
|
|
58
|
+
return not self.failed
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class IontCharger:
|
|
62
|
+
"""An IONT charger reached through a ``ModbusUnit``.
|
|
63
|
+
|
|
64
|
+
The caller owns the connection and hands over a unit. Prefer
|
|
65
|
+
:meth:`async_probe`, which asks the charger how many connectors it has.
|
|
66
|
+
The constructor serves a caller that already knows.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, unit: ModbusUnit, *, connectors: int) -> None:
|
|
70
|
+
"""Set up the components for a charger with ``connectors`` connectors."""
|
|
71
|
+
if not 1 <= connectors <= MAX_CONNECTORS:
|
|
72
|
+
msg = f"connectors must be between 1 and {MAX_CONNECTORS}, got {connectors}"
|
|
73
|
+
raise IontError(msg)
|
|
74
|
+
|
|
75
|
+
self._unit = unit
|
|
76
|
+
self._command_lock = asyncio.Lock()
|
|
77
|
+
self._result = CommandResult(unit)
|
|
78
|
+
|
|
79
|
+
self.device = Device(unit)
|
|
80
|
+
self.settings = Settings(unit)
|
|
81
|
+
self.connectors: list[Connector] = [
|
|
82
|
+
Connector(unit, base_offset=CONNECTOR_BASE + CONNECTOR_STRIDE * index)
|
|
83
|
+
for index in range(connectors)
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
async def async_probe(cls, unit: ModbusUnit) -> IontCharger:
|
|
88
|
+
"""Read the charger's layout on ``unit`` and return a ready instance.
|
|
89
|
+
|
|
90
|
+
Raises :class:`IontConnectionError` when the device does not answer,
|
|
91
|
+
and :class:`IontError` when what answers does not present as an IONT
|
|
92
|
+
charger.
|
|
93
|
+
"""
|
|
94
|
+
device = Device(unit)
|
|
95
|
+
try:
|
|
96
|
+
await device.async_update(notify=False)
|
|
97
|
+
except ModbusError as err:
|
|
98
|
+
raise IontConnectionError(str(err)) from err
|
|
99
|
+
|
|
100
|
+
count = device.connector_count
|
|
101
|
+
if count is None or not 1 <= count <= MAX_CONNECTORS:
|
|
102
|
+
msg = "The device does not answer as an IONT charger"
|
|
103
|
+
raise IontError(msg)
|
|
104
|
+
|
|
105
|
+
charger = cls(unit, connectors=count)
|
|
106
|
+
# Hand over what was just read rather than reading it again.
|
|
107
|
+
charger.device = device
|
|
108
|
+
return charger
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def connector_count(self) -> int:
|
|
112
|
+
"""How many connectors this charger serves."""
|
|
113
|
+
return len(self.connectors)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def components(self) -> list[Component]:
|
|
117
|
+
"""Every polled component, in read order."""
|
|
118
|
+
return [self.device, self.settings, *self.connectors]
|
|
119
|
+
|
|
120
|
+
def _targets(self) -> Iterator[tuple[str, Component]]:
|
|
121
|
+
"""Yield the polled sub-systems by name."""
|
|
122
|
+
yield SUBSYSTEM_DEVICE, self.device
|
|
123
|
+
yield SUBSYSTEM_SETTINGS, self.settings
|
|
124
|
+
for number, connector in enumerate(self.connectors, 1):
|
|
125
|
+
yield connector_subsystem(number), connector
|
|
126
|
+
|
|
127
|
+
async def async_update(self) -> UpdateReport:
|
|
128
|
+
"""Refresh every sub-system, each on its own, and report what answered."""
|
|
129
|
+
updated: set[str] = set()
|
|
130
|
+
failed: dict[str, IontError] = {}
|
|
131
|
+
|
|
132
|
+
for name, component in self._targets():
|
|
133
|
+
try:
|
|
134
|
+
await component.async_update()
|
|
135
|
+
except ModbusConnectionError as err:
|
|
136
|
+
raise IontConnectionError(str(err)) from err
|
|
137
|
+
except ModbusTimeoutError as err:
|
|
138
|
+
# Nothing has answered yet: the rest would only pay a timeout each.
|
|
139
|
+
if not updated and not failed:
|
|
140
|
+
raise IontConnectionError(str(err)) from err
|
|
141
|
+
failed[name] = IontConnectionError(str(err))
|
|
142
|
+
except ModbusError as err:
|
|
143
|
+
failed[name] = IontConnectionError(str(err))
|
|
144
|
+
else:
|
|
145
|
+
updated.add(name)
|
|
146
|
+
|
|
147
|
+
if failed and not updated:
|
|
148
|
+
msg = "No sub-system answered: " + "; ".join(map(str, failed.values()))
|
|
149
|
+
raise IontConnectionError(msg)
|
|
150
|
+
|
|
151
|
+
return UpdateReport(updated=updated, failed=failed)
|
|
152
|
+
|
|
153
|
+
async def async_read_raw(self) -> dict[str, dict[int, int | bool]]:
|
|
154
|
+
"""Every register this charger reads, undecoded, for diagnostics."""
|
|
155
|
+
raw: dict[str, dict[int, int | bool]] = {}
|
|
156
|
+
for _name, component in self._targets():
|
|
157
|
+
try:
|
|
158
|
+
read = await component.async_read_raw(notify=False)
|
|
159
|
+
except ModbusError as err:
|
|
160
|
+
raise IontConnectionError(str(err)) from err
|
|
161
|
+
for space, values in read.items():
|
|
162
|
+
raw.setdefault(space, {}).update(values)
|
|
163
|
+
return raw
|
|
164
|
+
|
|
165
|
+
# -- commands ------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def _connector(self, number: int) -> Connector:
|
|
168
|
+
"""Return the connector with a 1-based number."""
|
|
169
|
+
if not 1 <= number <= len(self.connectors):
|
|
170
|
+
msg = f"connector {number} does not exist (1 to {len(self.connectors)})"
|
|
171
|
+
raise IontError(msg)
|
|
172
|
+
return self.connectors[number - 1]
|
|
173
|
+
|
|
174
|
+
def _connector_id(self, number: int) -> int:
|
|
175
|
+
"""Return the OCPP connector ID the charger knows a connector by."""
|
|
176
|
+
connector_id = self._connector(number).connector_id
|
|
177
|
+
if not connector_id:
|
|
178
|
+
msg = f"connector {number} has no connector ID, so it cannot be addressed"
|
|
179
|
+
raise IontError(msg)
|
|
180
|
+
return connector_id
|
|
181
|
+
|
|
182
|
+
async def async_authorize(self, number: int, *, boost: bool = False) -> None:
|
|
183
|
+
"""Authorize charging on a connector, by its 1-based number.
|
|
184
|
+
|
|
185
|
+
With ``boost`` the connector charges at full available current even
|
|
186
|
+
under the eco strategy, instead of waiting for surplus power. That
|
|
187
|
+
command addresses the connector by its OCPP connector ID, so it needs
|
|
188
|
+
one to be set.
|
|
189
|
+
"""
|
|
190
|
+
if boost:
|
|
191
|
+
await self._async_command(
|
|
192
|
+
AUTHORIZE_BOOST_BY_CONNECTOR_ID_ADDRESS, self._connector_id(number)
|
|
193
|
+
)
|
|
194
|
+
return
|
|
195
|
+
connector = self._connector(number)
|
|
196
|
+
await self._async_command(
|
|
197
|
+
connector.base_address + CONNECTOR_AUTHORIZE_OFFSET, 1
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
async def async_deauthorize(self, number: int) -> None:
|
|
201
|
+
"""Withdraw the authorization of a connector, by its 1-based number."""
|
|
202
|
+
connector = self._connector(number)
|
|
203
|
+
await self._async_command(
|
|
204
|
+
connector.base_address + CONNECTOR_DEAUTHORIZE_OFFSET, 1
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
async def async_authorize_by_id(self, connector_id: int) -> None:
|
|
208
|
+
"""Authorize charging on the connector with an OCPP connector ID."""
|
|
209
|
+
await self._async_command(AUTHORIZE_BY_CONNECTOR_ID_ADDRESS, connector_id)
|
|
210
|
+
|
|
211
|
+
async def async_deauthorize_by_id(self, connector_id: int) -> None:
|
|
212
|
+
"""Withdraw the authorization of the connector with an OCPP connector ID."""
|
|
213
|
+
await self._async_command(DEAUTHORIZE_BY_CONNECTOR_ID_ADDRESS, connector_id)
|
|
214
|
+
|
|
215
|
+
async def async_set_external_power_limit(self, watts: int) -> None:
|
|
216
|
+
"""Cap the charging power from outside, for example from a home energy manager.
|
|
217
|
+
|
|
218
|
+
Writing 0 stops charging; writing :data:`EXTERNAL_POWER_LIMIT_MAX`
|
|
219
|
+
lifts the cap. The charger reads the cap back as the effective limit.
|
|
220
|
+
"""
|
|
221
|
+
try:
|
|
222
|
+
await self.settings.write("external_power_limit", watts)
|
|
223
|
+
except ModbusError as err:
|
|
224
|
+
raise IontConnectionError(str(err)) from err
|
|
225
|
+
|
|
226
|
+
async def async_clear_external_power_limit(self) -> None:
|
|
227
|
+
"""Lift the external charging power cap."""
|
|
228
|
+
await self.async_set_external_power_limit(EXTERNAL_POWER_LIMIT_MAX)
|
|
229
|
+
|
|
230
|
+
async def _async_command(self, address: int, value: int) -> None:
|
|
231
|
+
"""Write a command trigger and wait for the charger to carry it out.
|
|
232
|
+
|
|
233
|
+
The charger processes triggers on its own cycle: it carries the command
|
|
234
|
+
out, records the result and resets the trigger to 0. Commands are
|
|
235
|
+
serialized because the result register is shared between them.
|
|
236
|
+
"""
|
|
237
|
+
async with self._command_lock:
|
|
238
|
+
try:
|
|
239
|
+
await self._unit.write_register(address, value)
|
|
240
|
+
await self._async_wait_for_trigger(address)
|
|
241
|
+
await self._result.async_update(notify=False)
|
|
242
|
+
except ModbusError as err:
|
|
243
|
+
raise IontConnectionError(str(err)) from err
|
|
244
|
+
|
|
245
|
+
if (code := self._result.code) != COMMAND_RESULT_OK:
|
|
246
|
+
msg = f"The charger rejected the command (result code {code})"
|
|
247
|
+
raise IontCommandError(msg, code=code)
|
|
248
|
+
|
|
249
|
+
async def _async_wait_for_trigger(self, address: int) -> None:
|
|
250
|
+
"""Poll a trigger register until the charger resets it."""
|
|
251
|
+
loop = asyncio.get_running_loop()
|
|
252
|
+
deadline = loop.time() + COMMAND_TIMEOUT
|
|
253
|
+
while True:
|
|
254
|
+
await asyncio.sleep(COMMAND_POLL_INTERVAL)
|
|
255
|
+
(pending,) = await self._unit.read_holding_registers(address, 1)
|
|
256
|
+
if pending == 0:
|
|
257
|
+
return
|
|
258
|
+
if loop.time() >= deadline:
|
|
259
|
+
msg = "The charger did not process the command in time"
|
|
260
|
+
raise IontCommandError(msg)
|
pyiont/components.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Register components of an IONT charger, modelled on ``modbus-connection``.
|
|
2
|
+
|
|
3
|
+
Each :class:`~modbus_connection.model.Component` maps one block of the
|
|
4
|
+
charger's register map to typed attributes. Multi-register values are
|
|
5
|
+
big-endian in both byte and word order, which is the library default.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from modbus_connection.model import (
|
|
13
|
+
Component,
|
|
14
|
+
boolean,
|
|
15
|
+
enum,
|
|
16
|
+
float32,
|
|
17
|
+
gauge,
|
|
18
|
+
int32,
|
|
19
|
+
integer,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from .const import (
|
|
23
|
+
COMMAND_RESULT_ADDRESS,
|
|
24
|
+
DEVICE_BASE,
|
|
25
|
+
EXTERNAL_POWER_LIMIT_ADDRESS,
|
|
26
|
+
EXTERNAL_POWER_LIMIT_MAX,
|
|
27
|
+
AuthorizedBy,
|
|
28
|
+
ChargingState,
|
|
29
|
+
ChargingStrategy,
|
|
30
|
+
ConnectionState,
|
|
31
|
+
CurrentFlow,
|
|
32
|
+
DeviceStatus,
|
|
33
|
+
VehicleState,
|
|
34
|
+
)
|
|
35
|
+
from .exceptions import IontError
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from modbus_connection import ModbusUnit
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _power_limit(value: Any) -> int:
|
|
42
|
+
"""Vet an external power limit before it is written."""
|
|
43
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
44
|
+
msg = f"power limit must be a number, got {value!r}"
|
|
45
|
+
raise IontError(msg)
|
|
46
|
+
watts = int(value)
|
|
47
|
+
if not 0 <= watts <= EXTERNAL_POWER_LIMIT_MAX:
|
|
48
|
+
msg = f"power limit {watts} W is out of range (0 to {EXTERNAL_POWER_LIMIT_MAX})"
|
|
49
|
+
raise IontError(msg)
|
|
50
|
+
return watts
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Device(Component):
|
|
54
|
+
"""The device-wide block: configuration, limits and overall status."""
|
|
55
|
+
|
|
56
|
+
register_space = "input"
|
|
57
|
+
register_ranges = ((DEVICE_BASE, DEVICE_BASE + 0x00B),)
|
|
58
|
+
|
|
59
|
+
main_breaker_current = integer(DEVICE_BASE + 0x000, signed=False, unit="A")
|
|
60
|
+
"""Current limit of the main circuit breaker, as configured."""
|
|
61
|
+
|
|
62
|
+
charger_breaker_current = integer(DEVICE_BASE + 0x001, signed=False, unit="A")
|
|
63
|
+
"""Current limit of the charger-side circuit breaker."""
|
|
64
|
+
|
|
65
|
+
phase_count = integer(DEVICE_BASE + 0x002, signed=False)
|
|
66
|
+
"""Number of grid phases the charger is wired for (1 or 3)."""
|
|
67
|
+
|
|
68
|
+
free_charging = boolean(DEVICE_BASE + 0x003)
|
|
69
|
+
"""Whether charging needs no authorization."""
|
|
70
|
+
|
|
71
|
+
power_limit_user = integer(DEVICE_BASE + 0x004, signed=False, unit="W")
|
|
72
|
+
"""User-configured charging power ceiling."""
|
|
73
|
+
|
|
74
|
+
available_power = integer(DEVICE_BASE + 0x005, signed=False, unit="W")
|
|
75
|
+
"""Charging power available right now, after every limiter."""
|
|
76
|
+
|
|
77
|
+
charging_strategy = enum(DEVICE_BASE + 0x006, ChargingStrategy)
|
|
78
|
+
"""How available power is distributed across the connectors."""
|
|
79
|
+
|
|
80
|
+
status = enum(DEVICE_BASE + 0x007, DeviceStatus)
|
|
81
|
+
"""Overall status of the charger."""
|
|
82
|
+
|
|
83
|
+
uptime = int32(DEVICE_BASE + 0x008, unit="s")
|
|
84
|
+
"""Seconds since the charger started."""
|
|
85
|
+
|
|
86
|
+
connector_count = int32(DEVICE_BASE + 0x00A)
|
|
87
|
+
"""Number of charging connectors the charger serves."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Settings(Component):
|
|
91
|
+
"""The writable device settings, served as holding registers."""
|
|
92
|
+
|
|
93
|
+
register_space = "holding"
|
|
94
|
+
register_ranges = ((EXTERNAL_POWER_LIMIT_ADDRESS, EXTERNAL_POWER_LIMIT_ADDRESS),)
|
|
95
|
+
|
|
96
|
+
external_power_limit = integer(
|
|
97
|
+
EXTERNAL_POWER_LIMIT_ADDRESS, signed=False, writable=_power_limit, unit="W"
|
|
98
|
+
)
|
|
99
|
+
"""External charging power cap (W); reads back as the effective limit."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class CommandResult(Component):
|
|
103
|
+
"""The result of the most recent command, as an input register."""
|
|
104
|
+
|
|
105
|
+
register_space = "input"
|
|
106
|
+
register_ranges = ((COMMAND_RESULT_ADDRESS, COMMAND_RESULT_ADDRESS + 1),)
|
|
107
|
+
|
|
108
|
+
code = int32(COMMAND_RESULT_ADDRESS)
|
|
109
|
+
"""Result code of the most recent command; 1 means it was carried out."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Connector(Component):
|
|
113
|
+
"""One charging connector's state and measurements.
|
|
114
|
+
|
|
115
|
+
Addresses are offsets within the connector block. Build one with
|
|
116
|
+
``base_offset`` set to the block's start on the device.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
register_space = "input"
|
|
120
|
+
register_ranges = ((0x00, 0x2B),)
|
|
121
|
+
|
|
122
|
+
def __init__(self, unit: ModbusUnit, *, base_offset: int) -> None:
|
|
123
|
+
"""Bind the connector block that starts at ``base_offset`` on the device."""
|
|
124
|
+
super().__init__(unit, base_offset=base_offset)
|
|
125
|
+
self.base_address = base_offset
|
|
126
|
+
"""First address of this connector's block on the device."""
|
|
127
|
+
|
|
128
|
+
identifier = int32(0x00)
|
|
129
|
+
"""32-bit identifier of the connector record on the charger."""
|
|
130
|
+
|
|
131
|
+
connector_id = integer(0x02, signed=False)
|
|
132
|
+
"""1-based connector ID as used by OCPP; 0 when none is set."""
|
|
133
|
+
|
|
134
|
+
current_flow = enum(0x03, CurrentFlow)
|
|
135
|
+
"""Whether this connector charges with AC or DC."""
|
|
136
|
+
|
|
137
|
+
connection_state = enum(0x04, ConnectionState)
|
|
138
|
+
"""Communication state between the charger and this connector's controller."""
|
|
139
|
+
|
|
140
|
+
vehicle_state = enum(0x05, VehicleState)
|
|
141
|
+
"""Physical state of the vehicle and cable."""
|
|
142
|
+
|
|
143
|
+
charging_state = enum(0x06, ChargingState)
|
|
144
|
+
"""What the connector is doing with the vehicle."""
|
|
145
|
+
|
|
146
|
+
charging = boolean(0x07)
|
|
147
|
+
"""True while energy is flowing."""
|
|
148
|
+
|
|
149
|
+
authorized = boolean(0x08)
|
|
150
|
+
"""True while the connector holds an authorization."""
|
|
151
|
+
|
|
152
|
+
authorized_by = enum(0x09, AuthorizedBy)
|
|
153
|
+
"""What granted the current authorization."""
|
|
154
|
+
|
|
155
|
+
power_limit = float32(0x0A, unit="W")
|
|
156
|
+
"""Configured charging power ceiling of this connector."""
|
|
157
|
+
|
|
158
|
+
power = int32(0x0C, unit="W")
|
|
159
|
+
"""Active power, summed over the phases."""
|
|
160
|
+
|
|
161
|
+
voltage_l1 = float32(0x0E, unit="V")
|
|
162
|
+
voltage_l2 = float32(0x10, unit="V")
|
|
163
|
+
voltage_l3 = float32(0x12, unit="V")
|
|
164
|
+
|
|
165
|
+
current_l1 = float32(0x14, unit="A")
|
|
166
|
+
current_l2 = float32(0x16, unit="A")
|
|
167
|
+
current_l3 = float32(0x18, unit="A")
|
|
168
|
+
|
|
169
|
+
frequency_l1 = float32(0x1A, unit="Hz")
|
|
170
|
+
frequency_l2 = float32(0x1C, unit="Hz")
|
|
171
|
+
frequency_l3 = float32(0x1E, unit="Hz")
|
|
172
|
+
|
|
173
|
+
session_energy = float32(0x20, unit="Wh")
|
|
174
|
+
"""Energy delivered in the current session."""
|
|
175
|
+
|
|
176
|
+
last_session_energy = float32(0x22, unit="Wh")
|
|
177
|
+
"""Energy delivered in the most recent finished session."""
|
|
178
|
+
|
|
179
|
+
total_energy = float32(0x24, unit="Wh")
|
|
180
|
+
"""Lifetime reading of the connector's energy meter."""
|
|
181
|
+
|
|
182
|
+
battery_soc = gauge(0x26, 0.1, signed=False, unit="%")
|
|
183
|
+
"""Vehicle battery state of charge; DC connectors only, AC reports 0."""
|
|
184
|
+
|
|
185
|
+
temperature_inner = float32(0x28, unit="°C")
|
|
186
|
+
"""Temperature inside the connector's electronics; 0 where not measured."""
|
|
187
|
+
|
|
188
|
+
temperature_ambient = float32(0x2A, unit="°C")
|
|
189
|
+
"""Ambient temperature at the connector; 0 where not measured."""
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def is_dc(self) -> bool:
|
|
193
|
+
"""Whether this connector charges with direct current."""
|
|
194
|
+
return self.current_flow is CurrentFlow.DC
|
pyiont/const.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Constants for the IONT charger Modbus TCP register map."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import IntEnum
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
DEFAULT_PORT: Final = 502
|
|
9
|
+
"""The TCP port an IONT charger listens on for Modbus requests."""
|
|
10
|
+
|
|
11
|
+
# -- Register layout -----------------------------------------------------------
|
|
12
|
+
# The map has a device-wide block and one block per charging connector. Device
|
|
13
|
+
# state is served as input registers (FC04); the few writable settings and
|
|
14
|
+
# command triggers are holding registers (FC03 / FC06).
|
|
15
|
+
|
|
16
|
+
DEVICE_BASE: Final = 0x1000
|
|
17
|
+
"""First address of the device-wide block."""
|
|
18
|
+
|
|
19
|
+
CONNECTOR_BASE: Final = 0x2000
|
|
20
|
+
"""First address of the first connector's block."""
|
|
21
|
+
|
|
22
|
+
CONNECTOR_STRIDE: Final = 0x80
|
|
23
|
+
"""Registers between the start of one connector block and the next."""
|
|
24
|
+
|
|
25
|
+
ADDRESS_SPACE_SIZE: Final = 0x7000
|
|
26
|
+
"""The highest address the charger serves, plus one."""
|
|
27
|
+
|
|
28
|
+
MAX_CONNECTORS: Final = (ADDRESS_SPACE_SIZE - CONNECTOR_BASE) // CONNECTOR_STRIDE
|
|
29
|
+
"""How many connector blocks fit in the address space."""
|
|
30
|
+
|
|
31
|
+
COMMAND_RESULT_ADDRESS: Final = DEVICE_BASE + 0x100
|
|
32
|
+
"""Input register (int32) holding the result of the most recent command."""
|
|
33
|
+
|
|
34
|
+
COMMAND_RESULT_OK: Final = 1
|
|
35
|
+
"""The result code the charger reports for a command it carried out."""
|
|
36
|
+
|
|
37
|
+
# Holding registers of the device block.
|
|
38
|
+
EXTERNAL_POWER_LIMIT_ADDRESS: Final = DEVICE_BASE + 0x002
|
|
39
|
+
AUTHORIZE_BY_CONNECTOR_ID_ADDRESS: Final = DEVICE_BASE + 0x010
|
|
40
|
+
DEAUTHORIZE_BY_CONNECTOR_ID_ADDRESS: Final = DEVICE_BASE + 0x011
|
|
41
|
+
AUTHORIZE_BOOST_BY_CONNECTOR_ID_ADDRESS: Final = DEVICE_BASE + 0x012
|
|
42
|
+
|
|
43
|
+
# Holding registers of a connector block, as offsets from the block start.
|
|
44
|
+
CONNECTOR_AUTHORIZE_OFFSET: Final = 0x01
|
|
45
|
+
CONNECTOR_DEAUTHORIZE_OFFSET: Final = 0x02
|
|
46
|
+
|
|
47
|
+
EXTERNAL_POWER_LIMIT_MAX: Final = 0xFFFF
|
|
48
|
+
"""The largest external power limit (W) the register holds; writing it lifts the cap."""
|
|
49
|
+
|
|
50
|
+
# -- Command handling ----------------------------------------------------------
|
|
51
|
+
# A command is a write to a trigger register. The charger picks it up on its
|
|
52
|
+
# own cycle, carries it out, records the result and resets the trigger to 0.
|
|
53
|
+
|
|
54
|
+
COMMAND_TIMEOUT: Final = 3.0
|
|
55
|
+
"""Seconds to wait for the charger to reset a command trigger."""
|
|
56
|
+
|
|
57
|
+
COMMAND_POLL_INTERVAL: Final = 0.1
|
|
58
|
+
"""Seconds between reads of a pending command trigger."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# -- Enumerations --------------------------------------------------------------
|
|
62
|
+
# The numeric codes the charger reports. Member names double as the
|
|
63
|
+
# machine-readable state names consumers show or translate.
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class DeviceStatus(IntEnum):
|
|
67
|
+
"""Overall status of the charger."""
|
|
68
|
+
|
|
69
|
+
UNKNOWN = 0
|
|
70
|
+
INIT = 1
|
|
71
|
+
OPERATIONAL = 2
|
|
72
|
+
OUT_OF_ORDER = 3
|
|
73
|
+
MAINTENANCE = 4
|
|
74
|
+
AUTH_NOT_POSSIBLE = 5
|
|
75
|
+
DEACTIVATED = 6
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ChargingStrategy(IntEnum):
|
|
79
|
+
"""How available power is distributed across the connectors."""
|
|
80
|
+
|
|
81
|
+
UNKNOWN = 0
|
|
82
|
+
NORMAL = 1
|
|
83
|
+
ECO = 2
|
|
84
|
+
BOOST = 3
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CurrentFlow(IntEnum):
|
|
88
|
+
"""Whether a connector charges with alternating or direct current."""
|
|
89
|
+
|
|
90
|
+
UNKNOWN = 0
|
|
91
|
+
AC = 1
|
|
92
|
+
DC = 2
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ConnectionState(IntEnum):
|
|
96
|
+
"""Communication state between the charger and a connector's controller."""
|
|
97
|
+
|
|
98
|
+
UNKNOWN = 0
|
|
99
|
+
OFFLINE = 1
|
|
100
|
+
ONLINE = 2
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class VehicleState(IntEnum):
|
|
104
|
+
"""Physical state of the vehicle and cable at a connector."""
|
|
105
|
+
|
|
106
|
+
UNKNOWN = 0
|
|
107
|
+
NOT_CONNECTED = 1
|
|
108
|
+
CONNECTED = 2
|
|
109
|
+
WANTS_TO_CHARGE = 3
|
|
110
|
+
NEEDS_TO_VENTILATE = 4
|
|
111
|
+
ERROR = 5
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ChargingState(IntEnum):
|
|
115
|
+
"""What a connector is doing with the vehicle."""
|
|
116
|
+
|
|
117
|
+
UNKNOWN = 0
|
|
118
|
+
NOT_CHARGING = 1
|
|
119
|
+
CHARGING_1F = 2
|
|
120
|
+
CHARGING_3F = 3
|
|
121
|
+
CHARGING_DC = 4
|
|
122
|
+
PAUSED = 5
|
|
123
|
+
CHARGING_DONE = 6
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class AuthorizedBy(IntEnum):
|
|
127
|
+
"""What authorized the current charging session."""
|
|
128
|
+
|
|
129
|
+
UNKNOWN = 0
|
|
130
|
+
NOT_CHARGING = 1
|
|
131
|
+
RFID = 2
|
|
132
|
+
TIMER = 3
|
|
133
|
+
REMOTE = 4
|
|
134
|
+
AUTO = 5
|
|
135
|
+
MODBUS = 6
|
|
136
|
+
MQTT = 7
|
|
137
|
+
OCPP = 8
|
pyiont/exceptions.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Exceptions raised by the IONT charger client."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class IontError(Exception):
|
|
5
|
+
"""Base exception for the IONT charger client."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class IontConnectionError(IontError):
|
|
9
|
+
"""Communicating with the charger over Modbus failed.
|
|
10
|
+
|
|
11
|
+
Wraps the backend-neutral error from ``modbus-connection``: a dead link, a
|
|
12
|
+
timeout, or a request the charger refused.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IontCommandError(IontError):
|
|
17
|
+
"""The charger did not carry out a command.
|
|
18
|
+
|
|
19
|
+
Raised when a command trigger is not processed in time, or when the charger
|
|
20
|
+
reports a result code other than success for it.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str, *, code: int | None = None) -> None:
|
|
24
|
+
"""Initialize the error with the result code the charger reported, if any."""
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.code = code
|
pyiont/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pyiont
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Asynchronous Python client for IONT EV chargers over Modbus TCP.
|
|
5
|
+
Project-URL: Homepage, https://github.com/IONTtech/pyiont
|
|
6
|
+
Project-URL: Repository, https://github.com/IONTtech/pyiont
|
|
7
|
+
Project-URL: Documentation, https://github.com/IONTtech/pyiont
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/IONTtech/pyiont/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/IONTtech/pyiont/releases
|
|
10
|
+
Author-email: IONT <info@iont.tech>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: async,ev-charger,evse,home-assistant,iont,modbus,wallbox
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Framework :: AsyncIO
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Home Automation
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.13
|
|
24
|
+
Requires-Dist: modbus-connection>=4.8.1
|
|
25
|
+
Provides-Extra: pymodbus
|
|
26
|
+
Requires-Dist: modbus-connection[pymodbus]>=4.8.1; extra == 'pymodbus'
|
|
27
|
+
Provides-Extra: tmodbus
|
|
28
|
+
Requires-Dist: modbus-connection[tmodbus]>=4.8.1; extra == 'tmodbus'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# pyiont
|
|
32
|
+
|
|
33
|
+
Asynchronous Python client for [IONT](https://iont.tech) EV chargers over
|
|
34
|
+
Modbus TCP.
|
|
35
|
+
|
|
36
|
+
The library is built on [modbus-connection](https://github.com/home-assistant-libs/modbus-connection):
|
|
37
|
+
it takes a `ModbusUnit`, maps the charger's register blocks to typed
|
|
38
|
+
attributes, and exposes the commands the charger accepts. It has no Home
|
|
39
|
+
Assistant dependency and is the device layer of the Home Assistant `iont`
|
|
40
|
+
integration.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install "pyiont[tmodbus]" # tmodbus backend
|
|
46
|
+
pip install "pyiont[pymodbus]" # pymodbus backend
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Prerequisites
|
|
50
|
+
|
|
51
|
+
Modbus TCP has to be enabled on the charger, in its administration interface
|
|
52
|
+
under **Protocols**. Writing (authorization, power limit) is a separate switch
|
|
53
|
+
there and is off by default. The charger listens on port `502`.
|
|
54
|
+
|
|
55
|
+
## Example
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import asyncio
|
|
59
|
+
|
|
60
|
+
from modbus_connection import ModbusTcpParams
|
|
61
|
+
from modbus_connection.tmodbus import ModbusConnection
|
|
62
|
+
|
|
63
|
+
from pyiont import IontCharger
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def main() -> None:
|
|
67
|
+
connection = ModbusConnection(ModbusTcpParams(host="192.168.1.60", port=502))
|
|
68
|
+
try:
|
|
69
|
+
charger = await IontCharger.async_probe(connection.for_unit(1))
|
|
70
|
+
report = await charger.async_update()
|
|
71
|
+
|
|
72
|
+
print("Status:", charger.device.status.name.lower())
|
|
73
|
+
print("Available power:", charger.device.available_power, "W")
|
|
74
|
+
for number, connector in enumerate(charger.connectors, 1):
|
|
75
|
+
print(
|
|
76
|
+
f"Connector {number}:",
|
|
77
|
+
connector.charging_state.name.lower(),
|
|
78
|
+
connector.power,
|
|
79
|
+
"W",
|
|
80
|
+
connector.session_energy,
|
|
81
|
+
"Wh",
|
|
82
|
+
)
|
|
83
|
+
print("Failed sub-systems:", report.failed)
|
|
84
|
+
|
|
85
|
+
await charger.async_authorize(1) # start charging on connector 1
|
|
86
|
+
await charger.async_set_external_power_limit(7_400) # cap at 7.4 kW
|
|
87
|
+
finally:
|
|
88
|
+
await connection.close()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
asyncio.run(main())
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## What it reads
|
|
95
|
+
|
|
96
|
+
- **Device**: breaker limits, phase count, free-charging mode, user power
|
|
97
|
+
ceiling, available power, charging strategy, status, uptime, connector count.
|
|
98
|
+
- **Settings**: the external power limit (writable).
|
|
99
|
+
- **Connector** (one block per connector): connection and vehicle state,
|
|
100
|
+
charging state, authorization and its source, power, per-phase voltage,
|
|
101
|
+
current and frequency, session, last-session and lifetime energy, battery
|
|
102
|
+
state of charge (DC), inner and ambient temperature.
|
|
103
|
+
|
|
104
|
+
`async_update()` reads each sub-system on its own and returns an
|
|
105
|
+
`UpdateReport` naming what refreshed and what failed, so one silent connector
|
|
106
|
+
does not blank the rest. A link that answers nothing raises
|
|
107
|
+
`IontConnectionError`.
|
|
108
|
+
|
|
109
|
+
## Commands
|
|
110
|
+
|
|
111
|
+
- `async_authorize(number, boost=False)` / `async_deauthorize(number)`: start
|
|
112
|
+
or stop charging on a connector by its 1-based number. `boost=True` charges at
|
|
113
|
+
full available current even under the eco strategy.
|
|
114
|
+
- `async_authorize_by_id(connector_id)` / `async_deauthorize_by_id(connector_id)`:
|
|
115
|
+
the same, addressing the connector by its OCPP connector ID.
|
|
116
|
+
- `async_set_external_power_limit(watts)` / `async_clear_external_power_limit()`:
|
|
117
|
+
cap the charging power from outside, for example from a home energy manager.
|
|
118
|
+
|
|
119
|
+
A command that the charger does not process in time, or reports a result
|
|
120
|
+
other than success for, raises `IontCommandError`.
|
|
121
|
+
|
|
122
|
+
## Develop
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
uv sync --extra tmodbus
|
|
126
|
+
uv run pytest
|
|
127
|
+
uv run mypy
|
|
128
|
+
uv run ruff check
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pyiont/__init__.py,sha256=8j-cvxCKTT4GciX3L3R4oS4VqE8Nb-jUPNcci9-xHYQ,1055
|
|
2
|
+
pyiont/charger.py,sha256=lMXT3ig7XiDrOw2BeZc_PbqjyKzyem4XvtiYiohvM5Y,10276
|
|
3
|
+
pyiont/components.py,sha256=mTJy5qgxWY0tZTiNy2BviNVup6V4zexMV0F-agMMtO8,6469
|
|
4
|
+
pyiont/const.py,sha256=K3zFgJajuIyFXqvnx3OJQ0zMJ_oR9DjvZ7vRO7FqPQc,3822
|
|
5
|
+
pyiont/exceptions.py,sha256=hnEyk4C3YsCYwtL1uAyyH3Y2oYEIVoxVFQApxIP1zV4,816
|
|
6
|
+
pyiont/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
pyiont-0.1.0.dist-info/METADATA,sha256=UTfh_JQvHAAKDUyOA_BOaf7oYsuRMXmJWd3t_NAJDRQ,4719
|
|
8
|
+
pyiont-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
9
|
+
pyiont-0.1.0.dist-info/licenses/LICENSE,sha256=mDPsm5n-_cg-o0AepANEdyvPK26EOKd5lFtL6x72GQY,1061
|
|
10
|
+
pyiont-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 IONT
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|