python-intelbras-solar-local 0.1.0__tar.gz → 0.1.1__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.
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/CHANGELOG.md +11 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/PKG-INFO +1 -1
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/docs/usage.md +6 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/pyproject.toml +1 -1
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/src/intelbras_solar_local/__init__.py +1 -1
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/src/intelbras_solar_local/modbus.py +22 -3
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/tests/conftest.py +10 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/tests/test_client.py +1 -1
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/tests/test_modbus.py +16 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/.github/workflows/docs.yml +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/.github/workflows/release.yml +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/.github/workflows/tests.yml +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/.gitignore +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/LICENSE +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/README.md +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/docs/api.md +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/docs/assets/icon.png +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/docs/assets/logo.png +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/docs/changelog.md +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/docs/index.md +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/docs/protocol.md +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/examples/status.py +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/mkdocs.yml +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/src/intelbras_solar_local/client.py +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/src/intelbras_solar_local/const.py +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/src/intelbras_solar_local/exceptions.py +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/src/intelbras_solar_local/models.py +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/src/intelbras_solar_local/py.typed +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/src/intelbras_solar_local/winet.py +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/tests/__init__.py +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/tests/test_models.py +0 -0
- {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/tests/test_winet.py +0 -0
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.1.1] - 2026-10-24
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- Malformed or mismatched Modbus replies now raise `IntelbrasSolarProtocolError`
|
|
9
|
+
and reopen the connection. Before, a bare `IndexError`/`ValueError` escaped.
|
|
10
|
+
The WiNet-S produced such frames when two clients polled at the same time
|
|
11
|
+
(observed on the real IONS-5K M5): bad MBAP length, wrong function code,
|
|
12
|
+
wrong byte count.
|
|
13
|
+
- The Modbus transaction id now starts at a random value, so two concurrent
|
|
14
|
+
clients no longer accept each other's replies.
|
|
15
|
+
|
|
5
16
|
## [0.1.0] - 2026-10-24
|
|
6
17
|
|
|
7
18
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: python-intelbras-solar-local
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Async Python client for Intelbras solar inverters (Sungrow WiNet-S) over Modbus TCP and the datalogger WebSocket
|
|
5
5
|
Project-URL: Homepage, https://github.com/hudsonbrendon/python-intelbras-solar-local
|
|
6
6
|
Project-URL: Documentation, https://hudsonbrendon.github.io/python-intelbras-solar-local/
|
|
@@ -31,6 +31,12 @@ asyncio.run(main())
|
|
|
31
31
|
To share an `aiohttp.ClientSession`, as Home Assistant does, pass `session=`. The client then leaves the session open
|
|
32
32
|
when it closes.
|
|
33
33
|
|
|
34
|
+
## One client at a time
|
|
35
|
+
|
|
36
|
+
The WiNet-S datalogger serves Modbus TCP poorly to concurrent clients: requests time out, and replies can arrive
|
|
37
|
+
malformed. The client raises `IntelbrasSolarProtocolError` for malformed replies and reopens the connection, so a bad
|
|
38
|
+
reply never turns into wrong data. Even so, keep one Modbus client per datalogger.
|
|
39
|
+
|
|
34
40
|
## Controls
|
|
35
41
|
|
|
36
42
|
!!! warning
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-intelbras-solar-local"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1"
|
|
8
8
|
description = "Async Python client for Intelbras solar inverters (Sungrow WiNet-S) over Modbus TCP and the datalogger WebSocket"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -6,6 +6,7 @@ clashes with the ``pymodbus`` version pinned by Home Assistant.
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
import asyncio
|
|
9
|
+
import random
|
|
9
10
|
import struct
|
|
10
11
|
|
|
11
12
|
from .const import (
|
|
@@ -19,6 +20,10 @@ from .exceptions import IntelbrasSolarConnectionError, IntelbrasSolarProtocolErr
|
|
|
19
20
|
_MBAP = struct.Struct(">HHHB")
|
|
20
21
|
|
|
21
22
|
|
|
23
|
+
class _FramingError(Exception):
|
|
24
|
+
"""Malformed frame; the connection must be reopened to resync."""
|
|
25
|
+
|
|
26
|
+
|
|
22
27
|
class ModbusTcpClient:
|
|
23
28
|
"""One persistent Modbus TCP connection, reopened on demand after errors."""
|
|
24
29
|
|
|
@@ -35,7 +40,9 @@ class ModbusTcpClient:
|
|
|
35
40
|
self.timeout = timeout
|
|
36
41
|
self._reader: asyncio.StreamReader | None = None
|
|
37
42
|
self._writer: asyncio.StreamWriter | None = None
|
|
38
|
-
|
|
43
|
+
# Random start: the WiNet-S can leak replies across concurrent clients,
|
|
44
|
+
# and two clients both counting from 1 would accept each other's frames.
|
|
45
|
+
self._tid = random.randrange(0x10000)
|
|
39
46
|
# The WiNet-S serves one request at a time; serialize every call.
|
|
40
47
|
self._lock = asyncio.Lock()
|
|
41
48
|
|
|
@@ -59,8 +66,13 @@ class ModbusTcpClient:
|
|
|
59
66
|
Mapping of register address to its unsigned 16-bit value.
|
|
60
67
|
"""
|
|
61
68
|
body = await self._request(struct.pack(">BHH", function, address, count))
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
if body[0] != function or len(body) != 2 + count * 2 or body[1] != count * 2:
|
|
70
|
+
# A reply to some other request: the stream is out of sync.
|
|
71
|
+
await self.async_close()
|
|
72
|
+
raise IntelbrasSolarProtocolError(
|
|
73
|
+
f"Modbus reply does not match function {function} x {count} registers"
|
|
74
|
+
)
|
|
75
|
+
values = struct.unpack(f">{count}H", body[2:])
|
|
64
76
|
return {address + i: v for i, v in enumerate(values)}
|
|
65
77
|
|
|
66
78
|
async def async_write(self, address: int, value: int) -> None:
|
|
@@ -76,6 +88,11 @@ class ModbusTcpClient:
|
|
|
76
88
|
raise IntelbrasSolarConnectionError(
|
|
77
89
|
f"Modbus {self.host}:{self.port}: {err!r}"
|
|
78
90
|
) from err
|
|
91
|
+
except _FramingError as err:
|
|
92
|
+
await self.async_close()
|
|
93
|
+
raise IntelbrasSolarProtocolError(
|
|
94
|
+
f"Modbus {self.host}:{self.port}: {err}"
|
|
95
|
+
) from err
|
|
79
96
|
if body[0] & 0x80:
|
|
80
97
|
raise IntelbrasSolarProtocolError(
|
|
81
98
|
f"Modbus exception {body[1]} on function {body[0] & 0x7F}"
|
|
@@ -93,6 +110,8 @@ class ModbusTcpClient:
|
|
|
93
110
|
await self._writer.drain()
|
|
94
111
|
while True:
|
|
95
112
|
tid, _, length, _ = _MBAP.unpack(await self._reader.readexactly(7))
|
|
113
|
+
if length < 3:
|
|
114
|
+
raise _FramingError(f"bad MBAP length {length}")
|
|
96
115
|
body = await self._reader.readexactly(length - 1)
|
|
97
116
|
# Drop late replies to a request that already timed out.
|
|
98
117
|
if tid == self._tid:
|
|
@@ -44,6 +44,8 @@ class FakeInverter:
|
|
|
44
44
|
self.input = dict(INPUT)
|
|
45
45
|
self.holding = dict(HOLDING)
|
|
46
46
|
self.writes: list[tuple[int, int, int]] = []
|
|
47
|
+
# Next replies to corrupt: "short_mbap" or "wrong_count".
|
|
48
|
+
self.garbage: list[str] = []
|
|
47
49
|
self.port = 0
|
|
48
50
|
self._server: asyncio.base_events.Server | None = None
|
|
49
51
|
|
|
@@ -76,6 +78,14 @@ class FakeInverter:
|
|
|
76
78
|
body = pdu
|
|
77
79
|
else:
|
|
78
80
|
body = bytes([fc | 0x80, 1])
|
|
81
|
+
if self.garbage:
|
|
82
|
+
mode = self.garbage.pop(0)
|
|
83
|
+
if mode == "short_mbap":
|
|
84
|
+
writer.write(struct.pack(">HHHB", tid, 0, 0, unit))
|
|
85
|
+
await writer.drain()
|
|
86
|
+
continue
|
|
87
|
+
if mode == "wrong_count":
|
|
88
|
+
body = bytes([fc, 2, 0, 1])
|
|
79
89
|
writer.write(struct.pack(">HHHB", tid, 0, len(body) + 1, unit) + body)
|
|
80
90
|
await writer.drain()
|
|
81
91
|
writer.close()
|
{python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/tests/test_modbus.py
RENAMED
|
@@ -34,3 +34,19 @@ async def test_unreachable_raises_connection_error(inverter):
|
|
|
34
34
|
client = ModbusTcpClient("127.0.0.1", port, timeout=1)
|
|
35
35
|
with pytest.raises(IntelbrasSolarConnectionError):
|
|
36
36
|
await client.async_read(4, 5000, 1)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def test_garbage_frames_raise_protocol_error_and_resync(inverter):
|
|
40
|
+
client = ModbusTcpClient("127.0.0.1", inverter.port)
|
|
41
|
+
inverter.garbage = ["short_mbap", "wrong_count"]
|
|
42
|
+
with pytest.raises(IntelbrasSolarProtocolError, match="MBAP length"):
|
|
43
|
+
await client.async_read(4, 5000, 3)
|
|
44
|
+
with pytest.raises(IntelbrasSolarProtocolError, match="does not match"):
|
|
45
|
+
await client.async_read(4, 5000, 3)
|
|
46
|
+
assert await client.async_read(4, 5000, 1) == {5000: 50}
|
|
47
|
+
await client.async_close()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_transaction_ids_start_randomly():
|
|
51
|
+
starts = {ModbusTcpClient("127.0.0.1")._tid for _ in range(20)}
|
|
52
|
+
assert len(starts) > 1
|
{python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/.github/workflows/docs.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/docs/assets/icon.png
RENAMED
|
File without changes
|
{python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/docs/assets/logo.png
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/examples/status.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/tests/test_models.py
RENAMED
|
File without changes
|
{python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/tests/test_winet.py
RENAMED
|
File without changes
|