python-intelbras-solar-local 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.
- intelbras_solar_local/__init__.py +43 -0
- intelbras_solar_local/client.py +138 -0
- intelbras_solar_local/const.py +55 -0
- intelbras_solar_local/exceptions.py +14 -0
- intelbras_solar_local/modbus.py +99 -0
- intelbras_solar_local/models.py +188 -0
- intelbras_solar_local/py.typed +0 -0
- intelbras_solar_local/winet.py +72 -0
- python_intelbras_solar_local-0.1.0.dist-info/METADATA +122 -0
- python_intelbras_solar_local-0.1.0.dist-info/RECORD +12 -0
- python_intelbras_solar_local-0.1.0.dist-info/WHEEL +4 -0
- python_intelbras_solar_local-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Async Python client for Intelbras solar inverters (Sungrow WiNet-S) over the LAN."""
|
|
2
|
+
from .client import IntelbrasSolarClient
|
|
3
|
+
from .const import (
|
|
4
|
+
DEFAULT_PORT,
|
|
5
|
+
DEFAULT_UNIT,
|
|
6
|
+
DEFAULT_WINET_PORT,
|
|
7
|
+
DEVICE_MODELS,
|
|
8
|
+
WORK_STATES,
|
|
9
|
+
)
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
IntelbrasSolarConnectionError,
|
|
12
|
+
IntelbrasSolarError,
|
|
13
|
+
IntelbrasSolarProtocolError,
|
|
14
|
+
)
|
|
15
|
+
from .modbus import ModbusTcpClient
|
|
16
|
+
from .models import (
|
|
17
|
+
DataloggerStatus,
|
|
18
|
+
InverterStatus,
|
|
19
|
+
Mppt,
|
|
20
|
+
parse_datalogger,
|
|
21
|
+
parse_registers,
|
|
22
|
+
)
|
|
23
|
+
from .winet import WiNetClient
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"IntelbrasSolarClient",
|
|
27
|
+
"InverterStatus",
|
|
28
|
+
"DataloggerStatus",
|
|
29
|
+
"Mppt",
|
|
30
|
+
"parse_registers",
|
|
31
|
+
"parse_datalogger",
|
|
32
|
+
"ModbusTcpClient",
|
|
33
|
+
"WiNetClient",
|
|
34
|
+
"IntelbrasSolarError",
|
|
35
|
+
"IntelbrasSolarConnectionError",
|
|
36
|
+
"IntelbrasSolarProtocolError",
|
|
37
|
+
"DEFAULT_PORT",
|
|
38
|
+
"DEFAULT_UNIT",
|
|
39
|
+
"DEFAULT_WINET_PORT",
|
|
40
|
+
"DEVICE_MODELS",
|
|
41
|
+
"WORK_STATES",
|
|
42
|
+
]
|
|
43
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Async client for Intelbras solar inverters over the local network."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
|
|
10
|
+
from .const import (
|
|
11
|
+
DEFAULT_PORT,
|
|
12
|
+
DEFAULT_UNIT,
|
|
13
|
+
DEFAULT_WINET_PORT,
|
|
14
|
+
DISABLE,
|
|
15
|
+
ENABLE,
|
|
16
|
+
FC_READ_HOLDING,
|
|
17
|
+
FC_READ_INPUT,
|
|
18
|
+
HOLDING_BLOCKS,
|
|
19
|
+
INPUT_BLOCKS,
|
|
20
|
+
REG_CLOCK,
|
|
21
|
+
REG_POWER_LIMIT,
|
|
22
|
+
REG_POWER_LIMIT_SWITCH,
|
|
23
|
+
REG_START_STOP,
|
|
24
|
+
START,
|
|
25
|
+
STOP,
|
|
26
|
+
)
|
|
27
|
+
from .exceptions import IntelbrasSolarError
|
|
28
|
+
from .modbus import ModbusTcpClient
|
|
29
|
+
from .models import InverterStatus, parse_registers
|
|
30
|
+
from .winet import WiNetClient
|
|
31
|
+
|
|
32
|
+
_LOGGER = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class IntelbrasSolarClient:
|
|
36
|
+
"""Talks to one inverter through its WiNet-S datalogger.
|
|
37
|
+
|
|
38
|
+
Modbus TCP is the primary source; the WiNet-S WebSocket adds datalogger
|
|
39
|
+
connectivity, the fault list and the commercial model, best-effort.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
host: IP or hostname of the WiNet-S datalogger.
|
|
43
|
+
port: Modbus TCP port.
|
|
44
|
+
unit: Modbus unit id of the inverter.
|
|
45
|
+
session: optional ``aiohttp`` session for the WebSocket; one is created
|
|
46
|
+
(and closed by :meth:`async_close`) when omitted.
|
|
47
|
+
winet_port: WebSocket port of the datalogger web UI.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
host: str,
|
|
53
|
+
port: int = DEFAULT_PORT,
|
|
54
|
+
unit: int = DEFAULT_UNIT,
|
|
55
|
+
session: aiohttp.ClientSession | None = None,
|
|
56
|
+
winet_port: int = DEFAULT_WINET_PORT,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.host = host
|
|
59
|
+
self.port = port
|
|
60
|
+
self.unit = unit
|
|
61
|
+
self.winet_port = winet_port
|
|
62
|
+
self._session = session
|
|
63
|
+
self._owns_session = session is None
|
|
64
|
+
self.modbus = ModbusTcpClient(host, port, unit)
|
|
65
|
+
|
|
66
|
+
async def __aenter__(self) -> IntelbrasSolarClient:
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
async def __aexit__(
|
|
70
|
+
self,
|
|
71
|
+
exc_type: type[BaseException] | None,
|
|
72
|
+
exc: BaseException | None,
|
|
73
|
+
tb: TracebackType | None,
|
|
74
|
+
) -> None:
|
|
75
|
+
await self.async_close()
|
|
76
|
+
|
|
77
|
+
async def async_close(self) -> None:
|
|
78
|
+
"""Close the Modbus connection and the session this client created."""
|
|
79
|
+
await self.modbus.async_close()
|
|
80
|
+
if self._owns_session and self._session is not None:
|
|
81
|
+
await self._session.close()
|
|
82
|
+
self._session = None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def winet(self) -> WiNetClient:
|
|
86
|
+
"""WebSocket client for the datalogger, sharing this client's session."""
|
|
87
|
+
if self._session is None:
|
|
88
|
+
self._session = aiohttp.ClientSession()
|
|
89
|
+
return WiNetClient(self.host, self._session, self.winet_port)
|
|
90
|
+
|
|
91
|
+
async def async_get_inverter_data(self) -> InverterStatus:
|
|
92
|
+
"""Read every polled register block over Modbus.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
IntelbrasSolarConnectionError: datalogger unreachable.
|
|
96
|
+
IntelbrasSolarProtocolError: Modbus exception reply.
|
|
97
|
+
"""
|
|
98
|
+
inp: dict[int, int] = {}
|
|
99
|
+
hold: dict[int, int] = {}
|
|
100
|
+
for start, count in INPUT_BLOCKS:
|
|
101
|
+
inp |= await self.modbus.async_read(FC_READ_INPUT, start, count)
|
|
102
|
+
for start, count in HOLDING_BLOCKS:
|
|
103
|
+
hold |= await self.modbus.async_read(FC_READ_HOLDING, start, count)
|
|
104
|
+
return parse_registers(inp, hold)
|
|
105
|
+
|
|
106
|
+
async def async_get_status(self) -> InverterStatus:
|
|
107
|
+
"""Modbus snapshot plus datalogger state (``datalogger`` may be None).
|
|
108
|
+
|
|
109
|
+
A WebSocket failure never fails the call; only Modbus errors raise.
|
|
110
|
+
"""
|
|
111
|
+
status = await self.async_get_inverter_data()
|
|
112
|
+
try:
|
|
113
|
+
status.datalogger = await self.winet.async_get_status()
|
|
114
|
+
except IntelbrasSolarError as err:
|
|
115
|
+
_LOGGER.debug("WiNet datalogger read failed: %s", err)
|
|
116
|
+
return status
|
|
117
|
+
|
|
118
|
+
async def async_set_inverter_on(self, on: bool) -> None:
|
|
119
|
+
"""Start (``True``) or stop (``False``) power generation."""
|
|
120
|
+
await self.modbus.async_write(REG_START_STOP, START if on else STOP)
|
|
121
|
+
|
|
122
|
+
async def async_set_power_limit_enabled(self, enabled: bool) -> None:
|
|
123
|
+
"""Turn the active power limitation on or off."""
|
|
124
|
+
await self.modbus.async_write(
|
|
125
|
+
REG_POWER_LIMIT_SWITCH, ENABLE if enabled else DISABLE
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async def async_set_power_limit(self, percent: float) -> None:
|
|
129
|
+
"""Set the active power limit, 0–100 % of nominal power."""
|
|
130
|
+
if not 0 <= percent <= 100:
|
|
131
|
+
raise ValueError("percent must be between 0 and 100")
|
|
132
|
+
await self.modbus.async_write(REG_POWER_LIMIT, round(percent * 10))
|
|
133
|
+
|
|
134
|
+
async def async_set_clock(self, when: datetime) -> None:
|
|
135
|
+
"""Set the inverter clock (one register per field)."""
|
|
136
|
+
fields = (when.year, when.month, when.day, when.hour, when.minute, when.second)
|
|
137
|
+
for offset, value in enumerate(fields):
|
|
138
|
+
await self.modbus.async_write(REG_CLOCK + offset, value)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Protocol constants for Intelbras (Sungrow WiNet-S) inverters.
|
|
2
|
+
|
|
3
|
+
Register addresses are 0-based, as sent on the wire. Sungrow's public
|
|
4
|
+
documentation numbers them 1-based, so ``doc address - 1`` maps to the
|
|
5
|
+
values below.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
DEFAULT_PORT = 502
|
|
10
|
+
DEFAULT_UNIT = 1
|
|
11
|
+
DEFAULT_WINET_PORT = 8082
|
|
12
|
+
WINET_WS_PATH = "/ws/home/overview"
|
|
13
|
+
|
|
14
|
+
MODBUS_TIMEOUT = 5
|
|
15
|
+
WINET_TIMEOUT = 10
|
|
16
|
+
|
|
17
|
+
FC_READ_HOLDING = 3
|
|
18
|
+
FC_READ_INPUT = 4
|
|
19
|
+
FC_WRITE_SINGLE = 6
|
|
20
|
+
|
|
21
|
+
# (start, count) blocks polled on every cycle. The holding block stops before
|
|
22
|
+
# 5019: reading it in a block fails with Modbus exception 4 on the IONS-5K M5.
|
|
23
|
+
INPUT_BLOCKS: tuple[tuple[int, int], ...] = ((4989, 61), (5050, 100), (5225, 2))
|
|
24
|
+
HOLDING_BLOCKS: tuple[tuple[int, int], ...] = ((4999, 9),)
|
|
25
|
+
|
|
26
|
+
# Writable holding registers
|
|
27
|
+
REG_CLOCK = 4999 # year, month, day, hour, minute, second (6 registers)
|
|
28
|
+
REG_START_STOP = 5005
|
|
29
|
+
REG_POWER_LIMIT_SWITCH = 5006
|
|
30
|
+
REG_POWER_LIMIT = 5007 # 0.1 %
|
|
31
|
+
|
|
32
|
+
START = 0xCF
|
|
33
|
+
STOP = 0xCE
|
|
34
|
+
ENABLE = 0xAA
|
|
35
|
+
DISABLE = 0x55
|
|
36
|
+
|
|
37
|
+
WORK_STATES: dict[int, str] = {
|
|
38
|
+
0x0000: "running",
|
|
39
|
+
0x8000: "stopped",
|
|
40
|
+
0x1300: "key_stop",
|
|
41
|
+
0x1500: "emergency_stop",
|
|
42
|
+
0x1400: "standby",
|
|
43
|
+
0x1200: "initial_standby",
|
|
44
|
+
0x1600: "starting",
|
|
45
|
+
0x9100: "alarm_run",
|
|
46
|
+
0x8100: "derating_run",
|
|
47
|
+
0x8200: "dispatch_run",
|
|
48
|
+
0x5500: "fault",
|
|
49
|
+
0x2500: "communication_fault",
|
|
50
|
+
}
|
|
51
|
+
PRODUCING_STATES = frozenset({0x0000, 0x9100, 0x8100, 0x8200})
|
|
52
|
+
FAULT_STATE = 0x5500
|
|
53
|
+
|
|
54
|
+
# Sungrow device type codes seen behind the Intelbras brand.
|
|
55
|
+
DEVICE_MODELS: dict[int, str] = {0x2614: "IONS-5K M5"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Exceptions for the Intelbras Solar local client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class IntelbrasSolarError(Exception):
|
|
6
|
+
"""Base error for all Intelbras Solar client failures."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class IntelbrasSolarConnectionError(IntelbrasSolarError):
|
|
10
|
+
"""Raised when the datalogger is unreachable or the transport fails."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class IntelbrasSolarProtocolError(IntelbrasSolarError):
|
|
14
|
+
"""Raised when the device answers with an error (Modbus exception, bad reply)."""
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Minimal asyncio Modbus TCP client (FC3, FC4, FC6).
|
|
2
|
+
|
|
3
|
+
Only what the inverter needs, with no third-party dependency, so it never
|
|
4
|
+
clashes with the ``pymodbus`` version pinned by Home Assistant.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import struct
|
|
10
|
+
|
|
11
|
+
from .const import (
|
|
12
|
+
DEFAULT_PORT,
|
|
13
|
+
DEFAULT_UNIT,
|
|
14
|
+
FC_WRITE_SINGLE,
|
|
15
|
+
MODBUS_TIMEOUT,
|
|
16
|
+
)
|
|
17
|
+
from .exceptions import IntelbrasSolarConnectionError, IntelbrasSolarProtocolError
|
|
18
|
+
|
|
19
|
+
_MBAP = struct.Struct(">HHHB")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ModbusTcpClient:
|
|
23
|
+
"""One persistent Modbus TCP connection, reopened on demand after errors."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
host: str,
|
|
28
|
+
port: int = DEFAULT_PORT,
|
|
29
|
+
unit: int = DEFAULT_UNIT,
|
|
30
|
+
timeout: float = MODBUS_TIMEOUT,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.host = host
|
|
33
|
+
self.port = port
|
|
34
|
+
self.unit = unit
|
|
35
|
+
self.timeout = timeout
|
|
36
|
+
self._reader: asyncio.StreamReader | None = None
|
|
37
|
+
self._writer: asyncio.StreamWriter | None = None
|
|
38
|
+
self._tid = 0
|
|
39
|
+
# The WiNet-S serves one request at a time; serialize every call.
|
|
40
|
+
self._lock = asyncio.Lock()
|
|
41
|
+
|
|
42
|
+
async def async_close(self) -> None:
|
|
43
|
+
"""Close the TCP connection (it reopens on the next request)."""
|
|
44
|
+
if self._writer is not None:
|
|
45
|
+
self._writer.close()
|
|
46
|
+
self._reader = self._writer = None
|
|
47
|
+
|
|
48
|
+
async def async_read(
|
|
49
|
+
self, function: int, address: int, count: int
|
|
50
|
+
) -> dict[int, int]:
|
|
51
|
+
"""Read ``count`` registers starting at ``address``.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
function: ``3`` (holding) or ``4`` (input).
|
|
55
|
+
address: 0-based start register.
|
|
56
|
+
count: number of registers (max 125).
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Mapping of register address to its unsigned 16-bit value.
|
|
60
|
+
"""
|
|
61
|
+
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])
|
|
64
|
+
return {address + i: v for i, v in enumerate(values)}
|
|
65
|
+
|
|
66
|
+
async def async_write(self, address: int, value: int) -> None:
|
|
67
|
+
"""Write one holding register (FC6)."""
|
|
68
|
+
await self._request(struct.pack(">BHH", FC_WRITE_SINGLE, address, value))
|
|
69
|
+
|
|
70
|
+
async def _request(self, pdu: bytes) -> bytes:
|
|
71
|
+
async with self._lock:
|
|
72
|
+
try:
|
|
73
|
+
body = await asyncio.wait_for(self._roundtrip(pdu), self.timeout)
|
|
74
|
+
except (OSError, TimeoutError, asyncio.IncompleteReadError) as err:
|
|
75
|
+
await self.async_close()
|
|
76
|
+
raise IntelbrasSolarConnectionError(
|
|
77
|
+
f"Modbus {self.host}:{self.port}: {err!r}"
|
|
78
|
+
) from err
|
|
79
|
+
if body[0] & 0x80:
|
|
80
|
+
raise IntelbrasSolarProtocolError(
|
|
81
|
+
f"Modbus exception {body[1]} on function {body[0] & 0x7F}"
|
|
82
|
+
)
|
|
83
|
+
return body
|
|
84
|
+
|
|
85
|
+
async def _roundtrip(self, pdu: bytes) -> bytes:
|
|
86
|
+
if self._writer is None:
|
|
87
|
+
self._reader, self._writer = await asyncio.open_connection(
|
|
88
|
+
self.host, self.port
|
|
89
|
+
)
|
|
90
|
+
assert self._reader is not None
|
|
91
|
+
self._tid = (self._tid + 1) & 0xFFFF
|
|
92
|
+
self._writer.write(_MBAP.pack(self._tid, 0, len(pdu) + 1, self.unit) + pdu)
|
|
93
|
+
await self._writer.drain()
|
|
94
|
+
while True:
|
|
95
|
+
tid, _, length, _ = _MBAP.unpack(await self._reader.readexactly(7))
|
|
96
|
+
body = await self._reader.readexactly(length - 1)
|
|
97
|
+
# Drop late replies to a request that already timed out.
|
|
98
|
+
if tid == self._tid:
|
|
99
|
+
return body
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Typed models parsed from inverter registers and datalogger messages."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import struct
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .const import (
|
|
10
|
+
DEVICE_MODELS,
|
|
11
|
+
ENABLE,
|
|
12
|
+
FAULT_STATE,
|
|
13
|
+
PRODUCING_STATES,
|
|
14
|
+
REG_POWER_LIMIT,
|
|
15
|
+
REG_POWER_LIMIT_SWITCH,
|
|
16
|
+
REG_START_STOP,
|
|
17
|
+
STOP,
|
|
18
|
+
WORK_STATES,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Mppt:
|
|
24
|
+
"""One MPPT (string) input."""
|
|
25
|
+
|
|
26
|
+
voltage: float
|
|
27
|
+
current: float
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def power(self) -> int:
|
|
31
|
+
"""DC power of this input in W."""
|
|
32
|
+
return round(self.voltage * self.current)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class DataloggerStatus:
|
|
37
|
+
"""State reported by the WiNet-S WebSocket."""
|
|
38
|
+
|
|
39
|
+
model: str | None = None
|
|
40
|
+
wifi_connected: bool | None = None
|
|
41
|
+
cloud_connected: bool | None = None
|
|
42
|
+
total_faults: int = 0
|
|
43
|
+
total_alarms: int = 0
|
|
44
|
+
faults: list[dict[str, Any]] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class InverterStatus:
|
|
49
|
+
"""Snapshot of the inverter at a point in time.
|
|
50
|
+
|
|
51
|
+
``online`` is ``False`` when the datalogger could not be read (typically at
|
|
52
|
+
night, when the inverter powers down); every other field is then ``None``.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
online: bool = True
|
|
56
|
+
serial: str | None = None
|
|
57
|
+
device_type_code: int | None = None
|
|
58
|
+
nominal_power: float | None = None # kW
|
|
59
|
+
energy_today: float | None = None # kWh
|
|
60
|
+
energy_month: float | None = None # kWh
|
|
61
|
+
energy_year: float | None = None # kWh
|
|
62
|
+
energy_total: float | None = None # kWh
|
|
63
|
+
running_hours_total: int | None = None
|
|
64
|
+
running_minutes_today: int | None = None
|
|
65
|
+
temperature: float | None = None # °C
|
|
66
|
+
power_ac: int | None = None # W
|
|
67
|
+
power_dc: int | None = None # W
|
|
68
|
+
power_apparent: int | None = None # VA
|
|
69
|
+
power_reactive: int | None = None # var
|
|
70
|
+
power_factor: float | None = None
|
|
71
|
+
grid_voltage: float | None = None # V
|
|
72
|
+
grid_current: float | None = None # A
|
|
73
|
+
grid_frequency: float | None = None # Hz
|
|
74
|
+
bus_voltage: float | None = None # V
|
|
75
|
+
insulation_resistance: int | None = None # kΩ
|
|
76
|
+
work_state_code: int | None = None
|
|
77
|
+
fault_code: int | None = None
|
|
78
|
+
inverter_on: bool | None = None
|
|
79
|
+
power_limit_enabled: bool | None = None
|
|
80
|
+
power_limit: float | None = None # %
|
|
81
|
+
clock: datetime | None = None
|
|
82
|
+
mppts: list[Mppt] = field(default_factory=list)
|
|
83
|
+
datalogger: DataloggerStatus | None = None
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def model(self) -> str | None:
|
|
87
|
+
"""Commercial model: from the datalogger, else the device type table."""
|
|
88
|
+
if self.datalogger and self.datalogger.model:
|
|
89
|
+
return self.datalogger.model
|
|
90
|
+
if self.device_type_code is None:
|
|
91
|
+
return None
|
|
92
|
+
return DEVICE_MODELS.get(
|
|
93
|
+
self.device_type_code, f"Sungrow 0x{self.device_type_code:04X}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def work_state(self) -> str | None:
|
|
98
|
+
"""Work state as a snake_case key (see ``WORK_STATES``)."""
|
|
99
|
+
if self.work_state_code is None:
|
|
100
|
+
return None
|
|
101
|
+
return WORK_STATES.get(self.work_state_code, "unknown")
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def producing(self) -> bool | None:
|
|
105
|
+
"""True while the inverter is feeding the grid."""
|
|
106
|
+
if self.work_state_code is None:
|
|
107
|
+
return None
|
|
108
|
+
return self.work_state_code in PRODUCING_STATES
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def has_fault(self) -> bool | None:
|
|
112
|
+
"""True on a Modbus fault code, a fault state or a datalogger fault."""
|
|
113
|
+
if self.work_state_code is None:
|
|
114
|
+
return None
|
|
115
|
+
return bool(
|
|
116
|
+
self.fault_code
|
|
117
|
+
or self.work_state_code == FAULT_STATE
|
|
118
|
+
or (self.datalogger and self.datalogger.total_faults)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_registers(inp: dict[int, int], hold: dict[int, int]) -> InverterStatus:
|
|
123
|
+
"""Build an :class:`InverterStatus` from raw input and holding registers.
|
|
124
|
+
|
|
125
|
+
Sungrow stores 32-bit values low word first.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def u32(a: int) -> int:
|
|
129
|
+
return inp[a] | inp[a + 1] << 16
|
|
130
|
+
|
|
131
|
+
def s16(v: int) -> int:
|
|
132
|
+
return v - 0x10000 if v & 0x8000 else v
|
|
133
|
+
|
|
134
|
+
def s32(a: int) -> int:
|
|
135
|
+
v = u32(a)
|
|
136
|
+
return v - 0x100000000 if v & 0x80000000 else v
|
|
137
|
+
|
|
138
|
+
serial = b"".join(struct.pack(">H", inp[a]) for a in range(4989, 4999))
|
|
139
|
+
try:
|
|
140
|
+
clock = datetime(*(hold[a] for a in range(4999, 5005)))
|
|
141
|
+
except ValueError:
|
|
142
|
+
clock = None
|
|
143
|
+
return InverterStatus(
|
|
144
|
+
serial=serial.decode("ascii", "ignore").strip("\x00 "),
|
|
145
|
+
device_type_code=inp[4999],
|
|
146
|
+
nominal_power=inp[5000] / 10,
|
|
147
|
+
energy_today=inp[5002] / 10,
|
|
148
|
+
energy_month=u32(5127) / 10,
|
|
149
|
+
energy_year=u32(5225) / 10,
|
|
150
|
+
energy_total=u32(5143) / 10,
|
|
151
|
+
running_hours_total=u32(5005),
|
|
152
|
+
running_minutes_today=inp[5112],
|
|
153
|
+
temperature=s16(inp[5007]) / 10,
|
|
154
|
+
power_apparent=u32(5008),
|
|
155
|
+
power_dc=u32(5016),
|
|
156
|
+
power_ac=u32(5030),
|
|
157
|
+
power_reactive=s32(5032),
|
|
158
|
+
power_factor=s16(inp[5034]) / 1000,
|
|
159
|
+
grid_voltage=inp[5018] / 10,
|
|
160
|
+
grid_current=inp[5021] / 10,
|
|
161
|
+
grid_frequency=inp[5147] / 100,
|
|
162
|
+
bus_voltage=inp[5146] / 10,
|
|
163
|
+
insulation_resistance=inp[5070],
|
|
164
|
+
work_state_code=inp[5037],
|
|
165
|
+
fault_code=inp[5044],
|
|
166
|
+
inverter_on=hold[REG_START_STOP] != STOP,
|
|
167
|
+
power_limit_enabled=hold[REG_POWER_LIMIT_SWITCH] == ENABLE,
|
|
168
|
+
power_limit=hold[REG_POWER_LIMIT] / 10,
|
|
169
|
+
clock=clock,
|
|
170
|
+
# Two MPPTs on the IONS-5K M5; models with a third use 5014/5015.
|
|
171
|
+
mppts=[Mppt(inp[a] / 10, inp[a + 1] / 10) for a in (5010, 5012)],
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def parse_datalogger(
|
|
176
|
+
state: dict[str, Any], faults: dict[str, Any], devices: dict[str, Any]
|
|
177
|
+
) -> DataloggerStatus:
|
|
178
|
+
"""Build a :class:`DataloggerStatus` from WiNet-S ``result_data`` payloads."""
|
|
179
|
+
device = (devices.get("list") or [{}])[0]
|
|
180
|
+
return DataloggerStatus(
|
|
181
|
+
model=device.get("dev_model"),
|
|
182
|
+
wifi_connected=state.get("wifi_conn_sts") == "1",
|
|
183
|
+
cloud_connected=state.get("cloud_conn_sts") == "1",
|
|
184
|
+
total_faults=int(state.get("total_fault", 0) or 0),
|
|
185
|
+
total_alarms=int(state.get("total_alarm", 0) or 0),
|
|
186
|
+
faults=list(faults.get("list") or []),
|
|
187
|
+
)
|
|
188
|
+
|
|
File without changes
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""WebSocket client for the WiNet-S datalogger web UI (port 8082).
|
|
2
|
+
|
|
3
|
+
No login is needed to read: ``connect`` hands out a session token that the
|
|
4
|
+
other read services accept.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import aiohttp
|
|
13
|
+
|
|
14
|
+
from .const import DEFAULT_WINET_PORT, WINET_TIMEOUT, WINET_WS_PATH
|
|
15
|
+
from .exceptions import IntelbrasSolarConnectionError, IntelbrasSolarProtocolError
|
|
16
|
+
from .models import DataloggerStatus, parse_datalogger
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WiNetClient:
|
|
20
|
+
"""Reads datalogger state, faults and the device list over WebSocket."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
host: str,
|
|
25
|
+
session: aiohttp.ClientSession,
|
|
26
|
+
port: int = DEFAULT_WINET_PORT,
|
|
27
|
+
timeout: float = WINET_TIMEOUT,
|
|
28
|
+
) -> None:
|
|
29
|
+
self.url = f"ws://{host}:{port}{WINET_WS_PATH}"
|
|
30
|
+
self._session = session
|
|
31
|
+
self._timeout = timeout
|
|
32
|
+
|
|
33
|
+
async def async_get_status(self) -> DataloggerStatus:
|
|
34
|
+
"""Open a session, read ``state``, ``fault`` and ``devicelist``, close."""
|
|
35
|
+
try:
|
|
36
|
+
async with asyncio.timeout(self._timeout):
|
|
37
|
+
async with self._session.ws_connect(self.url) as ws:
|
|
38
|
+
token = (await _call(ws, "connect", token=""))["token"]
|
|
39
|
+
state = await _call(ws, "state", token=token)
|
|
40
|
+
faults = await _call(ws, "fault", token=token)
|
|
41
|
+
devices = await _call(
|
|
42
|
+
ws, "devicelist", token=token, type="0", is_check_token="0"
|
|
43
|
+
)
|
|
44
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
45
|
+
raise IntelbrasSolarConnectionError(f"WiNet {self.url}: {err!r}") from err
|
|
46
|
+
return parse_datalogger(state, faults, devices)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _call(
|
|
50
|
+
ws: aiohttp.ClientWebSocketResponse, service: str, **extra: Any
|
|
51
|
+
) -> dict[str, Any]:
|
|
52
|
+
"""Send one request and wait for the reply of the same ``service``.
|
|
53
|
+
|
|
54
|
+
The server also pushes unsolicited ``notice`` messages; they are skipped.
|
|
55
|
+
"""
|
|
56
|
+
await ws.send_str(json.dumps({"lang": "en_us", "service": service, **extra}))
|
|
57
|
+
async for msg in ws:
|
|
58
|
+
if msg.type is not aiohttp.WSMsgType.TEXT:
|
|
59
|
+
continue
|
|
60
|
+
try:
|
|
61
|
+
reply = json.loads(msg.data)
|
|
62
|
+
except ValueError as err:
|
|
63
|
+
raise IntelbrasSolarProtocolError(f"WiNet {service}: bad JSON") from err
|
|
64
|
+
data = reply.get("result_data") or {}
|
|
65
|
+
if data.get("service") != service:
|
|
66
|
+
continue
|
|
67
|
+
if reply.get("result_code") != 1:
|
|
68
|
+
raise IntelbrasSolarProtocolError(
|
|
69
|
+
f"WiNet {service}: {reply.get('result_msg')}"
|
|
70
|
+
)
|
|
71
|
+
return data
|
|
72
|
+
raise IntelbrasSolarConnectionError(f"WiNet {service}: connection closed")
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: python-intelbras-solar-local
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python client for Intelbras solar inverters (Sungrow WiNet-S) over Modbus TCP and the datalogger WebSocket
|
|
5
|
+
Project-URL: Homepage, https://github.com/hudsonbrendon/python-intelbras-solar-local
|
|
6
|
+
Project-URL: Documentation, https://hudsonbrendon.github.io/python-intelbras-solar-local/
|
|
7
|
+
Project-URL: Issues, https://github.com/hudsonbrendon/python-intelbras-solar-local/issues
|
|
8
|
+
Author-email: Hudson Brendon <contato.hudsonbrendon@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: aiohttp,async,home-assistant,intelbras,inverter,modbus,solar,sungrow,winet
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: aiohttp>=3.9
|
|
21
|
+
Provides-Extra: docs
|
|
22
|
+
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
23
|
+
Requires-Dist: mkdocstrings[python]>=0.25; extra == 'docs'
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
26
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
27
|
+
Requires-Dist: ruff; extra == 'test'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
<p align="center"><img src="docs/assets/logo.png" width="300" alt="Intelbras"></p>
|
|
31
|
+
|
|
32
|
+
# python-intelbras-solar-local
|
|
33
|
+
|
|
34
|
+
[](https://github.com/hudsonbrendon/python-intelbras-solar-local/actions/workflows/tests.yml)
|
|
35
|
+
[](https://pypi.org/project/python-intelbras-solar-local/)
|
|
36
|
+
[](https://pypi.org/project/python-intelbras-solar-local/)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
|
|
39
|
+
This is an async Python client that reads and controls **Intelbras solar inverters** over the local network, with no cloud
|
|
40
|
+
and no account. It was built for the
|
|
41
|
+
[`ha-intelbras-solar-local`](https://github.com/hudsonbrendon/ha-intelbras-solar-local) Home Assistant integration.
|
|
42
|
+
|
|
43
|
+
Intelbras IONS inverters are rebranded **Sungrow** string inverters, and their Wi-Fi datalogger is a Sungrow **WiNet-S**.
|
|
44
|
+
The datalogger exposes two interfaces without a login, and this library uses both:
|
|
45
|
+
|
|
46
|
+
- **Modbus TCP** on port `502`, using the Sungrow SG-RS register map. This is the main data source.
|
|
47
|
+
- The **datalogger WebSocket** on port `8082`, which the built-in web UI uses. It provides Wi-Fi and cloud link state,
|
|
48
|
+
active faults and alarms, and the commercial model.
|
|
49
|
+
|
|
50
|
+
## Features
|
|
51
|
+
|
|
52
|
+
- ⚡ Power readings: AC, DC, apparent and reactive power, plus power factor.
|
|
53
|
+
- 🔋 Energy counters for today, the month, the year and the lifetime total (0.1 kWh resolution).
|
|
54
|
+
- ☀️ Voltage, current and power for each MPPT input.
|
|
55
|
+
- 🔌 Grid voltage, current and frequency (0.01 Hz).
|
|
56
|
+
- 🩺 Health data: work state, internal temperature, bus voltage, insulation resistance and fault code.
|
|
57
|
+
- 📡 Datalogger data: Wi-Fi and cloud connectivity, active fault list and alarm count.
|
|
58
|
+
- 🛠️ Controls: start or stop the inverter, switch power limitation on or off, set the power limit (%), set the clock.
|
|
59
|
+
- No `pymodbus` dependency. The library ships a tiny asyncio Modbus TCP client, so it never clashes with the version
|
|
60
|
+
that Home Assistant pins.
|
|
61
|
+
- Fully async (`aiohttp`) and typed (`py.typed`).
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install python-intelbras-solar-local
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Quickstart
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import asyncio
|
|
73
|
+
from intelbras_solar_local import IntelbrasSolarClient
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def main() -> None:
|
|
77
|
+
async with IntelbrasSolarClient("192.168.31.17") as client:
|
|
78
|
+
status = await client.async_get_status()
|
|
79
|
+
print(status.model, status.serial, status.work_state)
|
|
80
|
+
print(f"{status.power_ac} W now, {status.energy_today} kWh today")
|
|
81
|
+
for n, mppt in enumerate(status.mppts, 1):
|
|
82
|
+
print(f"MPPT{n}: {mppt.voltage} V x {mppt.current} A = {mppt.power} W")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
asyncio.run(main())
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Supported hardware
|
|
89
|
+
|
|
90
|
+
| Inverter | Datalogger firmware | Sungrow type code |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| Intelbras IONS-5K M5 | `WIFI-SV300.001.03.P002` (WiNet-S) | `0x2614` |
|
|
93
|
+
|
|
94
|
+
Other Intelbras IONS models and Sungrow SG-RS inverters behind a WiNet-S should work as well.
|
|
95
|
+
If yours differs, open an issue with the output of `examples/status.py`.
|
|
96
|
+
|
|
97
|
+
## API overview
|
|
98
|
+
|
|
99
|
+
| Method | Description |
|
|
100
|
+
|--------|-------------|
|
|
101
|
+
| `async_get_status()` | Returns an `InverterStatus` from Modbus, with a `DataloggerStatus` when the WebSocket answers (best-effort) |
|
|
102
|
+
| `async_get_inverter_data()` | Modbus-only `InverterStatus` |
|
|
103
|
+
| `winet.async_get_status()` | Datalogger-only `DataloggerStatus` |
|
|
104
|
+
| `async_set_inverter_on(on)` | Starts or stops generation (holding register 5005) |
|
|
105
|
+
| `async_set_power_limit_enabled(enabled)` | Switches power limitation on or off (5006) |
|
|
106
|
+
| `async_set_power_limit(percent)` | Sets the power limit, 0–100 % (5007) |
|
|
107
|
+
| `async_set_clock(datetime)` | Sets the inverter clock (4999–5004) |
|
|
108
|
+
|
|
109
|
+
The register map and the WebSocket protocol are documented in the
|
|
110
|
+
[docs](https://hudsonbrendon.github.io/python-intelbras-solar-local/protocol/).
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
uv venv && uv pip install -e ".[test]"
|
|
116
|
+
pytest
|
|
117
|
+
ruff check .
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT © Hudson Brendon. This project is not affiliated with Intelbras or Sungrow.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
intelbras_solar_local/__init__.py,sha256=sB6R2ZazZlVSt1urJRLclV263luVJsX9JpRuNpSaWkc,972
|
|
2
|
+
intelbras_solar_local/client.py,sha256=P0wRlYhh-TfINIcV_zn8O73sJy-EbSDXoqJPR3TbiVY,4864
|
|
3
|
+
intelbras_solar_local/const.py,sha256=119Ha_oIqfjT02km-rOQXna18gxurQBONGLelMObIas,1548
|
|
4
|
+
intelbras_solar_local/exceptions.py,sha256=HLUYEQCusH6uTsYG68k6y6ZOHGRbsUHlLYvljUMd5hw,472
|
|
5
|
+
intelbras_solar_local/modbus.py,sha256=qnJAP0ZvffRsXp_jRYP1raKZEdWgHWfweOGG8DYqpGI,3542
|
|
6
|
+
intelbras_solar_local/models.py,sha256=vMStsRm9X0lZK4_AuEYHPAhAViNWuvo7YExPG98Jzds,6166
|
|
7
|
+
intelbras_solar_local/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
intelbras_solar_local/winet.py,sha256=wXyO9SSzx-dZ7ywR_NFSGp488wj04_HoqaOb7KbDqZQ,2721
|
|
9
|
+
python_intelbras_solar_local-0.1.0.dist-info/METADATA,sha256=fBnGdHez4RGoMf4OoNjN_MNfNhBd37-Ewvu_sMOpPIk,5533
|
|
10
|
+
python_intelbras_solar_local-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
python_intelbras_solar_local-0.1.0.dist-info/licenses/LICENSE,sha256=4Zgd3fbLJqWSkwKA-hh1rNN6DC9tNQ3rWLX93fingOw,1071
|
|
12
|
+
python_intelbras_solar_local-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hudson Brendon
|
|
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.
|