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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.