pyiont 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .coverage
8
+ coverage.xml
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
pyiont-0.1.0/LICENSE ADDED
@@ -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.
pyiont-0.1.0/PKG-INFO ADDED
@@ -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
pyiont-0.1.0/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # pyiont
2
+
3
+ Asynchronous Python client for [IONT](https://iont.tech) EV chargers over
4
+ Modbus TCP.
5
+
6
+ The library is built on [modbus-connection](https://github.com/home-assistant-libs/modbus-connection):
7
+ it takes a `ModbusUnit`, maps the charger's register blocks to typed
8
+ attributes, and exposes the commands the charger accepts. It has no Home
9
+ Assistant dependency and is the device layer of the Home Assistant `iont`
10
+ integration.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install "pyiont[tmodbus]" # tmodbus backend
16
+ pip install "pyiont[pymodbus]" # pymodbus backend
17
+ ```
18
+
19
+ ## Prerequisites
20
+
21
+ Modbus TCP has to be enabled on the charger, in its administration interface
22
+ under **Protocols**. Writing (authorization, power limit) is a separate switch
23
+ there and is off by default. The charger listens on port `502`.
24
+
25
+ ## Example
26
+
27
+ ```python
28
+ import asyncio
29
+
30
+ from modbus_connection import ModbusTcpParams
31
+ from modbus_connection.tmodbus import ModbusConnection
32
+
33
+ from pyiont import IontCharger
34
+
35
+
36
+ async def main() -> None:
37
+ connection = ModbusConnection(ModbusTcpParams(host="192.168.1.60", port=502))
38
+ try:
39
+ charger = await IontCharger.async_probe(connection.for_unit(1))
40
+ report = await charger.async_update()
41
+
42
+ print("Status:", charger.device.status.name.lower())
43
+ print("Available power:", charger.device.available_power, "W")
44
+ for number, connector in enumerate(charger.connectors, 1):
45
+ print(
46
+ f"Connector {number}:",
47
+ connector.charging_state.name.lower(),
48
+ connector.power,
49
+ "W",
50
+ connector.session_energy,
51
+ "Wh",
52
+ )
53
+ print("Failed sub-systems:", report.failed)
54
+
55
+ await charger.async_authorize(1) # start charging on connector 1
56
+ await charger.async_set_external_power_limit(7_400) # cap at 7.4 kW
57
+ finally:
58
+ await connection.close()
59
+
60
+
61
+ asyncio.run(main())
62
+ ```
63
+
64
+ ## What it reads
65
+
66
+ - **Device**: breaker limits, phase count, free-charging mode, user power
67
+ ceiling, available power, charging strategy, status, uptime, connector count.
68
+ - **Settings**: the external power limit (writable).
69
+ - **Connector** (one block per connector): connection and vehicle state,
70
+ charging state, authorization and its source, power, per-phase voltage,
71
+ current and frequency, session, last-session and lifetime energy, battery
72
+ state of charge (DC), inner and ambient temperature.
73
+
74
+ `async_update()` reads each sub-system on its own and returns an
75
+ `UpdateReport` naming what refreshed and what failed, so one silent connector
76
+ does not blank the rest. A link that answers nothing raises
77
+ `IontConnectionError`.
78
+
79
+ ## Commands
80
+
81
+ - `async_authorize(number, boost=False)` / `async_deauthorize(number)`: start
82
+ or stop charging on a connector by its 1-based number. `boost=True` charges at
83
+ full available current even under the eco strategy.
84
+ - `async_authorize_by_id(connector_id)` / `async_deauthorize_by_id(connector_id)`:
85
+ the same, addressing the connector by its OCPP connector ID.
86
+ - `async_set_external_power_limit(watts)` / `async_clear_external_power_limit()`:
87
+ cap the charging power from outside, for example from a home energy manager.
88
+
89
+ A command that the charger does not process in time, or reports a result
90
+ other than success for, raises `IontCommandError`.
91
+
92
+ ## Develop
93
+
94
+ ```bash
95
+ uv sync --extra tmodbus
96
+ uv run pytest
97
+ uv run mypy
98
+ uv run ruff check
99
+ ```
100
+
101
+ ## License
102
+
103
+ MIT
@@ -0,0 +1,99 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pyiont"
7
+ version = "0.1.0"
8
+ description = "Asynchronous Python client for IONT EV chargers over Modbus TCP."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "IONT", email = "info@iont.tech" }]
13
+ requires-python = ">=3.13"
14
+ keywords = [
15
+ "async",
16
+ "ev-charger",
17
+ "evse",
18
+ "home-assistant",
19
+ "iont",
20
+ "modbus",
21
+ "wallbox",
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 4 - Beta",
25
+ "Framework :: AsyncIO",
26
+ "Intended Audience :: Developers",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Programming Language :: Python :: 3.14",
30
+ "Topic :: Home Automation",
31
+ "Topic :: Software Development :: Libraries :: Python Modules",
32
+ "Typing :: Typed",
33
+ ]
34
+ dependencies = ["modbus-connection>=4.8.1"]
35
+
36
+ [project.optional-dependencies]
37
+ tmodbus = ["modbus-connection[tmodbus]>=4.8.1"]
38
+ pymodbus = ["modbus-connection[pymodbus]>=4.8.1"]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/IONTtech/pyiont"
42
+ Repository = "https://github.com/IONTtech/pyiont"
43
+ Documentation = "https://github.com/IONTtech/pyiont"
44
+ "Bug Tracker" = "https://github.com/IONTtech/pyiont/issues"
45
+ Changelog = "https://github.com/IONTtech/pyiont/releases"
46
+
47
+ [dependency-groups]
48
+ dev = [
49
+ "mypy>=1.14",
50
+ "pytest>=8.3",
51
+ "pytest-asyncio>=0.25",
52
+ "pytest-cov>=6.0",
53
+ "ruff>=0.9",
54
+ ]
55
+
56
+ [tool.hatch.build.targets.wheel]
57
+ packages = ["src/pyiont"]
58
+
59
+ [tool.hatch.build.targets.sdist]
60
+ include = ["src/pyiont", "tests", "README.md", "LICENSE"]
61
+
62
+ [tool.pytest.ini_options]
63
+ asyncio_mode = "auto"
64
+ addopts = "--strict-markers --strict-config"
65
+ testpaths = ["tests"]
66
+ xfail_strict = true
67
+
68
+ [tool.coverage.run]
69
+ source = ["pyiont"]
70
+ branch = true
71
+
72
+ [tool.coverage.report]
73
+ show_missing = true
74
+ fail_under = 100
75
+
76
+ [tool.mypy]
77
+ python_version = "3.13"
78
+ strict = true
79
+ files = ["src/pyiont"]
80
+
81
+ [tool.ruff]
82
+ target-version = "py313"
83
+ line-length = 88
84
+
85
+ [tool.ruff.lint]
86
+ select = ["ALL"]
87
+ ignore = [
88
+ "ANN401", # Any is needed for the generic write validators
89
+ "COM812", # handled by the formatter
90
+ "D203", # conflicts with D211
91
+ "D213", # conflicts with D212
92
+ "CPY001", # no per-file copyright headers
93
+ ]
94
+
95
+ [tool.ruff.lint.per-file-ignores]
96
+ "tests/**" = ["D", "PLR2004", "S101", "SLF001"]
97
+
98
+ [tool.ruff.lint.pydocstyle]
99
+ convention = "pep257"
@@ -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
+ ]
@@ -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)