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.
- pyiont-0.1.0/.gitignore +11 -0
- pyiont-0.1.0/LICENSE +21 -0
- pyiont-0.1.0/PKG-INFO +133 -0
- pyiont-0.1.0/README.md +103 -0
- pyiont-0.1.0/pyproject.toml +99 -0
- pyiont-0.1.0/src/pyiont/__init__.py +48 -0
- pyiont-0.1.0/src/pyiont/charger.py +260 -0
- pyiont-0.1.0/src/pyiont/components.py +194 -0
- pyiont-0.1.0/src/pyiont/const.py +137 -0
- pyiont-0.1.0/src/pyiont/exceptions.py +26 -0
- pyiont-0.1.0/src/pyiont/py.typed +0 -0
- pyiont-0.1.0/tests/__init__.py +1 -0
- pyiont-0.1.0/tests/conftest.py +185 -0
- pyiont-0.1.0/tests/test_charger.py +459 -0
pyiont-0.1.0/.gitignore
ADDED
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)
|