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.
@@ -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
+ [![Tests](https://github.com/hudsonbrendon/python-intelbras-solar-local/actions/workflows/tests.yml/badge.svg)](https://github.com/hudsonbrendon/python-intelbras-solar-local/actions/workflows/tests.yml)
35
+ [![PyPI version](https://img.shields.io/pypi/v/python-intelbras-solar-local.svg)](https://pypi.org/project/python-intelbras-solar-local/)
36
+ [![Python](https://img.shields.io/pypi/pyversions/python-intelbras-solar-local.svg)](https://pypi.org/project/python-intelbras-solar-local/)
37
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.