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.
Files changed (32) hide show
  1. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/CHANGELOG.md +11 -0
  2. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/PKG-INFO +1 -1
  3. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/docs/usage.md +6 -0
  4. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/pyproject.toml +1 -1
  5. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/src/intelbras_solar_local/__init__.py +1 -1
  6. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/src/intelbras_solar_local/modbus.py +22 -3
  7. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/tests/conftest.py +10 -0
  8. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/tests/test_client.py +1 -1
  9. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/tests/test_modbus.py +16 -0
  10. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/.github/workflows/docs.yml +0 -0
  11. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/.github/workflows/release.yml +0 -0
  12. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/.github/workflows/tests.yml +0 -0
  13. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/.gitignore +0 -0
  14. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/LICENSE +0 -0
  15. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/README.md +0 -0
  16. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/docs/api.md +0 -0
  17. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/docs/assets/icon.png +0 -0
  18. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/docs/assets/logo.png +0 -0
  19. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/docs/changelog.md +0 -0
  20. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/docs/index.md +0 -0
  21. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/docs/protocol.md +0 -0
  22. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/examples/status.py +0 -0
  23. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/mkdocs.yml +0 -0
  24. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/src/intelbras_solar_local/client.py +0 -0
  25. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/src/intelbras_solar_local/const.py +0 -0
  26. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/src/intelbras_solar_local/exceptions.py +0 -0
  27. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/src/intelbras_solar_local/models.py +0 -0
  28. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/src/intelbras_solar_local/py.typed +0 -0
  29. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/src/intelbras_solar_local/winet.py +0 -0
  30. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/tests/__init__.py +0 -0
  31. {python_intelbras_solar_local-0.1.0 → python_intelbras_solar_local-0.1.1}/tests/test_models.py +0 -0
  32. {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.0
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.0"
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"
@@ -40,4 +40,4 @@ __all__ = [
40
40
  "DEVICE_MODELS",
41
41
  "WORK_STATES",
42
42
  ]
43
- __version__ = "0.1.0"
43
+ __version__ = "0.1.1"
@@ -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
- self._tid = 0
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
- size = body[1]
63
- values = struct.unpack(f">{size // 2}H", body[2 : 2 + size])
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()
@@ -41,4 +41,4 @@ async def test_controls_write_expected_registers(inverter):
41
41
 
42
42
 
43
43
  def test_version():
44
- assert __version__ == "0.1.0"
44
+ assert __version__ == "0.1.1"
@@ -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