pylxpweb 0.1.0__py3-none-any.whl → 0.5.2__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.
- pylxpweb/__init__.py +47 -2
- pylxpweb/api_namespace.py +241 -0
- pylxpweb/cli/__init__.py +3 -0
- pylxpweb/cli/collect_device_data.py +874 -0
- pylxpweb/client.py +387 -26
- pylxpweb/constants/__init__.py +481 -0
- pylxpweb/constants/api.py +48 -0
- pylxpweb/constants/devices.py +98 -0
- pylxpweb/constants/locations.py +227 -0
- pylxpweb/{constants.py → constants/registers.py} +72 -238
- pylxpweb/constants/scaling.py +479 -0
- pylxpweb/devices/__init__.py +32 -0
- pylxpweb/devices/_firmware_update_mixin.py +504 -0
- pylxpweb/devices/_mid_runtime_properties.py +1427 -0
- pylxpweb/devices/base.py +122 -0
- pylxpweb/devices/battery.py +589 -0
- pylxpweb/devices/battery_bank.py +331 -0
- pylxpweb/devices/inverters/__init__.py +32 -0
- pylxpweb/devices/inverters/_features.py +378 -0
- pylxpweb/devices/inverters/_runtime_properties.py +596 -0
- pylxpweb/devices/inverters/base.py +2124 -0
- pylxpweb/devices/inverters/generic.py +192 -0
- pylxpweb/devices/inverters/hybrid.py +274 -0
- pylxpweb/devices/mid_device.py +183 -0
- pylxpweb/devices/models.py +126 -0
- pylxpweb/devices/parallel_group.py +364 -0
- pylxpweb/devices/station.py +908 -0
- pylxpweb/endpoints/control.py +980 -2
- pylxpweb/endpoints/devices.py +249 -16
- pylxpweb/endpoints/firmware.py +43 -10
- pylxpweb/endpoints/plants.py +15 -19
- pylxpweb/exceptions.py +4 -0
- pylxpweb/models.py +708 -41
- pylxpweb/transports/__init__.py +78 -0
- pylxpweb/transports/capabilities.py +101 -0
- pylxpweb/transports/data.py +501 -0
- pylxpweb/transports/exceptions.py +59 -0
- pylxpweb/transports/factory.py +119 -0
- pylxpweb/transports/http.py +329 -0
- pylxpweb/transports/modbus.py +617 -0
- pylxpweb/transports/protocol.py +217 -0
- {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.2.dist-info}/METADATA +130 -85
- pylxpweb-0.5.2.dist-info/RECORD +52 -0
- {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.2.dist-info}/WHEEL +1 -1
- pylxpweb-0.5.2.dist-info/entry_points.txt +3 -0
- pylxpweb-0.1.0.dist-info/RECORD +0 -19
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
"""Modbus TCP transport implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the ModbusTransport class for direct local
|
|
4
|
+
communication with inverters via Modbus TCP (typically through
|
|
5
|
+
a Waveshare RS485-to-Ethernet adapter).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from .capabilities import MODBUS_CAPABILITIES, TransportCapabilities
|
|
16
|
+
from .data import BatteryBankData, InverterEnergyData, InverterRuntimeData
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
TransportConnectionError,
|
|
19
|
+
TransportReadError,
|
|
20
|
+
TransportTimeoutError,
|
|
21
|
+
TransportWriteError,
|
|
22
|
+
)
|
|
23
|
+
from .protocol import BaseTransport
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from pymodbus.client import AsyncModbusTcpClient
|
|
27
|
+
|
|
28
|
+
_LOGGER = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Register group definitions for efficient reading
|
|
31
|
+
# Based on Modbus 40-register per call limit
|
|
32
|
+
INPUT_REGISTER_GROUPS = {
|
|
33
|
+
"power_energy": (0, 32), # Registers 0-31: Power, voltage, current
|
|
34
|
+
"status_energy": (32, 29), # Registers 32-60: Status, energy counters
|
|
35
|
+
"temperatures": (61, 15), # Registers 61-75: Temperatures, currents
|
|
36
|
+
"advanced": (76, 31), # Registers 76-106: Faults, SOH, PV energy
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
HOLD_REGISTER_GROUPS = {
|
|
40
|
+
"system": (0, 25), # Registers 0-24: System config
|
|
41
|
+
"grid_protection": (25, 35), # Registers 25-59: Grid protection
|
|
42
|
+
"charging": (60, 30), # Registers 60-89: Charging config
|
|
43
|
+
"battery": (90, 40), # Registers 90-129: Battery config
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Serial number is stored in input registers 115-119 (5 registers, 10 ASCII chars)
|
|
47
|
+
# Each register contains 2 ASCII characters: low byte = char[0], high byte = char[1]
|
|
48
|
+
SERIAL_NUMBER_START_REGISTER = 115
|
|
49
|
+
SERIAL_NUMBER_REGISTER_COUNT = 5
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ModbusTransport(BaseTransport):
|
|
53
|
+
"""Modbus TCP transport for local inverter communication.
|
|
54
|
+
|
|
55
|
+
This transport connects directly to the inverter via a Modbus TCP
|
|
56
|
+
gateway (e.g., Waveshare RS485-to-Ethernet adapter).
|
|
57
|
+
|
|
58
|
+
Example:
|
|
59
|
+
transport = ModbusTransport(
|
|
60
|
+
host="192.168.1.100",
|
|
61
|
+
port=502,
|
|
62
|
+
serial="CE12345678",
|
|
63
|
+
)
|
|
64
|
+
await transport.connect()
|
|
65
|
+
|
|
66
|
+
runtime = await transport.read_runtime()
|
|
67
|
+
print(f"PV Power: {runtime.pv_total_power}W")
|
|
68
|
+
|
|
69
|
+
Note:
|
|
70
|
+
Requires the `pymodbus` package to be installed:
|
|
71
|
+
uv add pymodbus
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
host: str,
|
|
77
|
+
port: int = 502,
|
|
78
|
+
unit_id: int = 1,
|
|
79
|
+
serial: str = "",
|
|
80
|
+
timeout: float = 10.0,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Initialize Modbus transport.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
host: IP address or hostname of Modbus TCP gateway
|
|
86
|
+
port: TCP port (default 502 for Modbus)
|
|
87
|
+
unit_id: Modbus unit/slave ID (default 1)
|
|
88
|
+
serial: Inverter serial number (for identification)
|
|
89
|
+
timeout: Connection and operation timeout in seconds
|
|
90
|
+
"""
|
|
91
|
+
super().__init__(serial)
|
|
92
|
+
self._host = host
|
|
93
|
+
self._port = port
|
|
94
|
+
self._unit_id = unit_id
|
|
95
|
+
self._timeout = timeout
|
|
96
|
+
self._client: AsyncModbusTcpClient | None = None
|
|
97
|
+
self._lock = asyncio.Lock()
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def capabilities(self) -> TransportCapabilities:
|
|
101
|
+
"""Get Modbus transport capabilities."""
|
|
102
|
+
return MODBUS_CAPABILITIES
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def host(self) -> str:
|
|
106
|
+
"""Get the Modbus gateway host."""
|
|
107
|
+
return self._host
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def port(self) -> int:
|
|
111
|
+
"""Get the Modbus gateway port."""
|
|
112
|
+
return self._port
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def unit_id(self) -> int:
|
|
116
|
+
"""Get the Modbus unit/slave ID."""
|
|
117
|
+
return self._unit_id
|
|
118
|
+
|
|
119
|
+
async def connect(self) -> None:
|
|
120
|
+
"""Establish Modbus TCP connection.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
TransportConnectionError: If connection fails
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
# Import pymodbus here to make it optional
|
|
127
|
+
from pymodbus.client import AsyncModbusTcpClient
|
|
128
|
+
|
|
129
|
+
self._client = AsyncModbusTcpClient(
|
|
130
|
+
host=self._host,
|
|
131
|
+
port=self._port,
|
|
132
|
+
timeout=self._timeout,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
connected = await self._client.connect()
|
|
136
|
+
if not connected:
|
|
137
|
+
raise TransportConnectionError(
|
|
138
|
+
f"Failed to connect to Modbus gateway at {self._host}:{self._port}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
self._connected = True
|
|
142
|
+
_LOGGER.info(
|
|
143
|
+
"Modbus transport connected to %s:%s (unit %s) for %s",
|
|
144
|
+
self._host,
|
|
145
|
+
self._port,
|
|
146
|
+
self._unit_id,
|
|
147
|
+
self._serial,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
except ImportError as err:
|
|
151
|
+
raise TransportConnectionError(
|
|
152
|
+
"pymodbus package not installed. Install with: uv add pymodbus"
|
|
153
|
+
) from err
|
|
154
|
+
except (TimeoutError, OSError) as err:
|
|
155
|
+
_LOGGER.error(
|
|
156
|
+
"Failed to connect to Modbus gateway at %s:%s: %s",
|
|
157
|
+
self._host,
|
|
158
|
+
self._port,
|
|
159
|
+
err,
|
|
160
|
+
)
|
|
161
|
+
raise TransportConnectionError(
|
|
162
|
+
f"Failed to connect to {self._host}:{self._port}: {err}. "
|
|
163
|
+
"Verify: (1) IP address is correct, (2) port 502 is not blocked, "
|
|
164
|
+
"(3) Modbus TCP is enabled on the inverter/datalogger."
|
|
165
|
+
) from err
|
|
166
|
+
|
|
167
|
+
async def disconnect(self) -> None:
|
|
168
|
+
"""Close Modbus TCP connection."""
|
|
169
|
+
if self._client:
|
|
170
|
+
self._client.close()
|
|
171
|
+
self._client = None
|
|
172
|
+
|
|
173
|
+
self._connected = False
|
|
174
|
+
_LOGGER.debug("Modbus transport disconnected for %s", self._serial)
|
|
175
|
+
|
|
176
|
+
async def _read_input_registers(
|
|
177
|
+
self,
|
|
178
|
+
address: int,
|
|
179
|
+
count: int,
|
|
180
|
+
) -> list[int]:
|
|
181
|
+
"""Read input registers (read-only runtime data).
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
address: Starting register address
|
|
185
|
+
count: Number of registers to read (max 40)
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
List of register values
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
TransportReadError: If read fails
|
|
192
|
+
"""
|
|
193
|
+
self._ensure_connected()
|
|
194
|
+
|
|
195
|
+
if self._client is None:
|
|
196
|
+
raise TransportConnectionError("Modbus client not initialized")
|
|
197
|
+
|
|
198
|
+
async with self._lock:
|
|
199
|
+
try:
|
|
200
|
+
result = await asyncio.wait_for(
|
|
201
|
+
self._client.read_input_registers(
|
|
202
|
+
address=address,
|
|
203
|
+
count=min(count, 40),
|
|
204
|
+
device_id=self._unit_id,
|
|
205
|
+
),
|
|
206
|
+
timeout=self._timeout,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if result.isError():
|
|
210
|
+
_LOGGER.error(
|
|
211
|
+
"Modbus error reading input registers at %d: %s",
|
|
212
|
+
address,
|
|
213
|
+
result,
|
|
214
|
+
)
|
|
215
|
+
raise TransportReadError(f"Modbus read error at address {address}: {result}")
|
|
216
|
+
|
|
217
|
+
if not hasattr(result, "registers") or result.registers is None:
|
|
218
|
+
_LOGGER.error(
|
|
219
|
+
"Invalid Modbus response at address %d: no registers",
|
|
220
|
+
address,
|
|
221
|
+
)
|
|
222
|
+
raise TransportReadError(
|
|
223
|
+
f"Invalid Modbus response at address {address}: no registers in response"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return list(result.registers)
|
|
227
|
+
|
|
228
|
+
except TimeoutError as err:
|
|
229
|
+
_LOGGER.error("Timeout reading input registers at %d", address)
|
|
230
|
+
raise TransportTimeoutError(
|
|
231
|
+
f"Timeout reading input registers at {address}"
|
|
232
|
+
) from err
|
|
233
|
+
except OSError as err:
|
|
234
|
+
_LOGGER.error("Failed to read input registers at %d: %s", address, err)
|
|
235
|
+
raise TransportReadError(
|
|
236
|
+
f"Failed to read input registers at {address}: {err}"
|
|
237
|
+
) from err
|
|
238
|
+
|
|
239
|
+
async def _read_holding_registers(
|
|
240
|
+
self,
|
|
241
|
+
address: int,
|
|
242
|
+
count: int,
|
|
243
|
+
) -> list[int]:
|
|
244
|
+
"""Read holding registers (configuration parameters).
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
address: Starting register address
|
|
248
|
+
count: Number of registers to read (max 40)
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
List of register values
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
TransportReadError: If read fails
|
|
255
|
+
"""
|
|
256
|
+
self._ensure_connected()
|
|
257
|
+
|
|
258
|
+
if self._client is None:
|
|
259
|
+
raise TransportConnectionError("Modbus client not initialized")
|
|
260
|
+
|
|
261
|
+
async with self._lock:
|
|
262
|
+
try:
|
|
263
|
+
result = await asyncio.wait_for(
|
|
264
|
+
self._client.read_holding_registers(
|
|
265
|
+
address=address,
|
|
266
|
+
count=min(count, 40),
|
|
267
|
+
device_id=self._unit_id,
|
|
268
|
+
),
|
|
269
|
+
timeout=self._timeout,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if result.isError():
|
|
273
|
+
_LOGGER.error(
|
|
274
|
+
"Modbus error reading holding registers at %d: %s",
|
|
275
|
+
address,
|
|
276
|
+
result,
|
|
277
|
+
)
|
|
278
|
+
raise TransportReadError(f"Modbus read error at address {address}: {result}")
|
|
279
|
+
|
|
280
|
+
if not hasattr(result, "registers") or result.registers is None:
|
|
281
|
+
_LOGGER.error(
|
|
282
|
+
"Invalid Modbus response at address %d: no registers",
|
|
283
|
+
address,
|
|
284
|
+
)
|
|
285
|
+
raise TransportReadError(
|
|
286
|
+
f"Invalid Modbus response at address {address}: no registers in response"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return list(result.registers)
|
|
290
|
+
|
|
291
|
+
except TimeoutError as err:
|
|
292
|
+
_LOGGER.error("Timeout reading holding registers at %d", address)
|
|
293
|
+
raise TransportTimeoutError(
|
|
294
|
+
f"Timeout reading holding registers at {address}"
|
|
295
|
+
) from err
|
|
296
|
+
except OSError as err:
|
|
297
|
+
_LOGGER.error("Failed to read holding registers at %d: %s", address, err)
|
|
298
|
+
raise TransportReadError(
|
|
299
|
+
f"Failed to read holding registers at {address}: {err}"
|
|
300
|
+
) from err
|
|
301
|
+
|
|
302
|
+
async def _write_holding_registers(
|
|
303
|
+
self,
|
|
304
|
+
address: int,
|
|
305
|
+
values: list[int],
|
|
306
|
+
) -> bool:
|
|
307
|
+
"""Write holding registers.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
address: Starting register address
|
|
311
|
+
values: List of values to write
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
True if write succeeded
|
|
315
|
+
|
|
316
|
+
Raises:
|
|
317
|
+
TransportWriteError: If write fails
|
|
318
|
+
"""
|
|
319
|
+
self._ensure_connected()
|
|
320
|
+
|
|
321
|
+
if self._client is None:
|
|
322
|
+
raise TransportConnectionError("Modbus client not initialized")
|
|
323
|
+
|
|
324
|
+
async with self._lock:
|
|
325
|
+
try:
|
|
326
|
+
result = await asyncio.wait_for(
|
|
327
|
+
self._client.write_registers(
|
|
328
|
+
address=address,
|
|
329
|
+
values=values,
|
|
330
|
+
device_id=self._unit_id,
|
|
331
|
+
),
|
|
332
|
+
timeout=self._timeout,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
if result.isError():
|
|
336
|
+
_LOGGER.error(
|
|
337
|
+
"Modbus error writing registers at %d: %s",
|
|
338
|
+
address,
|
|
339
|
+
result,
|
|
340
|
+
)
|
|
341
|
+
raise TransportWriteError(f"Modbus write error at address {address}: {result}")
|
|
342
|
+
|
|
343
|
+
return True
|
|
344
|
+
|
|
345
|
+
except TimeoutError as err:
|
|
346
|
+
_LOGGER.error("Timeout writing registers at %d", address)
|
|
347
|
+
raise TransportTimeoutError(f"Timeout writing registers at {address}") from err
|
|
348
|
+
except OSError as err:
|
|
349
|
+
_LOGGER.error("Failed to write registers at %d: %s", address, err)
|
|
350
|
+
raise TransportWriteError(f"Failed to write registers at {address}: {err}") from err
|
|
351
|
+
|
|
352
|
+
async def read_runtime(self) -> InverterRuntimeData:
|
|
353
|
+
"""Read runtime data via Modbus input registers.
|
|
354
|
+
|
|
355
|
+
Note: Register reads are serialized (not concurrent) to prevent
|
|
356
|
+
transaction ID desynchronization issues with pymodbus and some
|
|
357
|
+
Modbus TCP gateways (e.g., Waveshare RS485-to-Ethernet adapters).
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
Runtime data with all values properly scaled
|
|
361
|
+
|
|
362
|
+
Raises:
|
|
363
|
+
TransportReadError: If read operation fails
|
|
364
|
+
"""
|
|
365
|
+
# Read register groups sequentially to avoid transaction ID issues
|
|
366
|
+
# See: https://github.com/joyfulhouse/pylxpweb/issues/95
|
|
367
|
+
input_registers: dict[int, int] = {}
|
|
368
|
+
|
|
369
|
+
for group_name, (start, count) in INPUT_REGISTER_GROUPS.items():
|
|
370
|
+
try:
|
|
371
|
+
values = await self._read_input_registers(start, count)
|
|
372
|
+
for offset, value in enumerate(values):
|
|
373
|
+
input_registers[start + offset] = value
|
|
374
|
+
except Exception as e:
|
|
375
|
+
_LOGGER.error(
|
|
376
|
+
"Failed to read register group '%s': %s",
|
|
377
|
+
group_name,
|
|
378
|
+
e,
|
|
379
|
+
)
|
|
380
|
+
raise TransportReadError(
|
|
381
|
+
f"Failed to read register group '{group_name}': {e}"
|
|
382
|
+
) from e
|
|
383
|
+
|
|
384
|
+
return InverterRuntimeData.from_modbus_registers(input_registers)
|
|
385
|
+
|
|
386
|
+
async def read_energy(self) -> InverterEnergyData:
|
|
387
|
+
"""Read energy statistics via Modbus input registers.
|
|
388
|
+
|
|
389
|
+
Energy data comes from the same input registers as runtime data,
|
|
390
|
+
so we read the relevant groups.
|
|
391
|
+
|
|
392
|
+
Note: Register reads are serialized to prevent transaction ID issues.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Energy data with all values in kWh
|
|
396
|
+
|
|
397
|
+
Raises:
|
|
398
|
+
TransportReadError: If read operation fails
|
|
399
|
+
"""
|
|
400
|
+
# Read energy-related register groups sequentially
|
|
401
|
+
groups_needed = ["status_energy", "advanced"]
|
|
402
|
+
input_registers: dict[int, int] = {}
|
|
403
|
+
|
|
404
|
+
for group_name, (start, count) in INPUT_REGISTER_GROUPS.items():
|
|
405
|
+
if group_name not in groups_needed:
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
values = await self._read_input_registers(start, count)
|
|
410
|
+
for offset, value in enumerate(values):
|
|
411
|
+
input_registers[start + offset] = value
|
|
412
|
+
except Exception as e:
|
|
413
|
+
_LOGGER.error(
|
|
414
|
+
"Failed to read energy register group '%s': %s",
|
|
415
|
+
group_name,
|
|
416
|
+
e,
|
|
417
|
+
)
|
|
418
|
+
raise TransportReadError(
|
|
419
|
+
f"Failed to read energy register group '{group_name}': {e}"
|
|
420
|
+
) from e
|
|
421
|
+
|
|
422
|
+
return InverterEnergyData.from_modbus_registers(input_registers)
|
|
423
|
+
|
|
424
|
+
async def read_battery(self) -> BatteryBankData | None:
|
|
425
|
+
"""Read battery information via Modbus.
|
|
426
|
+
|
|
427
|
+
Note: Modbus provides limited battery data compared to HTTP API.
|
|
428
|
+
Individual battery module data is not available via Modbus.
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
Battery bank data with available information, None if no battery
|
|
432
|
+
|
|
433
|
+
Raises:
|
|
434
|
+
TransportReadError: If read operation fails
|
|
435
|
+
"""
|
|
436
|
+
# Battery data comes from input registers
|
|
437
|
+
# We need the power_energy group for battery voltage/current/SOC
|
|
438
|
+
# Note: _read_input_registers already raises appropriate Transport exceptions
|
|
439
|
+
power_regs = await self._read_input_registers(0, 32)
|
|
440
|
+
|
|
441
|
+
# Import scaling
|
|
442
|
+
from pylxpweb.constants.scaling import ScaleFactor, apply_scale
|
|
443
|
+
|
|
444
|
+
# Extract battery data from registers
|
|
445
|
+
battery_voltage = apply_scale(power_regs[4], ScaleFactor.SCALE_100) # INPUT_V_BAT
|
|
446
|
+
battery_soc = power_regs[5] # INPUT_SOC
|
|
447
|
+
|
|
448
|
+
# Battery charge/discharge power (2-register values)
|
|
449
|
+
charge_power = (power_regs[12] << 16) | power_regs[13]
|
|
450
|
+
discharge_power = (power_regs[14] << 16) | power_regs[15]
|
|
451
|
+
|
|
452
|
+
# If no battery voltage, assume no battery
|
|
453
|
+
if battery_voltage < 1.0:
|
|
454
|
+
_LOGGER.debug(
|
|
455
|
+
"Battery voltage %.2fV is below 1.0V threshold, assuming no battery present. "
|
|
456
|
+
"If batteries are installed, check Modbus register mapping.",
|
|
457
|
+
battery_voltage,
|
|
458
|
+
)
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
return BatteryBankData(
|
|
462
|
+
timestamp=datetime.now(),
|
|
463
|
+
voltage=battery_voltage,
|
|
464
|
+
soc=battery_soc,
|
|
465
|
+
charge_power=float(charge_power),
|
|
466
|
+
discharge_power=float(discharge_power),
|
|
467
|
+
# Limited data via Modbus
|
|
468
|
+
battery_count=1, # Assume at least one battery pack
|
|
469
|
+
batteries=[], # Individual battery data not available via Modbus
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
async def read_parameters(
|
|
473
|
+
self,
|
|
474
|
+
start_address: int,
|
|
475
|
+
count: int,
|
|
476
|
+
) -> dict[int, int]:
|
|
477
|
+
"""Read configuration parameters via Modbus holding registers.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
start_address: Starting register address
|
|
481
|
+
count: Number of registers to read (max 40 per call)
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
Dict mapping register address to raw integer value
|
|
485
|
+
|
|
486
|
+
Raises:
|
|
487
|
+
TransportReadError: If read operation fails
|
|
488
|
+
"""
|
|
489
|
+
result: dict[int, int] = {}
|
|
490
|
+
|
|
491
|
+
# Read in chunks of 40 registers (Modbus limit)
|
|
492
|
+
remaining = count
|
|
493
|
+
current_address = start_address
|
|
494
|
+
|
|
495
|
+
while remaining > 0:
|
|
496
|
+
chunk_size = min(remaining, 40)
|
|
497
|
+
values = await self._read_holding_registers(current_address, chunk_size)
|
|
498
|
+
|
|
499
|
+
for offset, value in enumerate(values):
|
|
500
|
+
result[current_address + offset] = value
|
|
501
|
+
|
|
502
|
+
current_address += chunk_size
|
|
503
|
+
remaining -= chunk_size
|
|
504
|
+
|
|
505
|
+
return result
|
|
506
|
+
|
|
507
|
+
async def write_parameters(
|
|
508
|
+
self,
|
|
509
|
+
parameters: dict[int, int],
|
|
510
|
+
) -> bool:
|
|
511
|
+
"""Write configuration parameters via Modbus holding registers.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
parameters: Dict mapping register address to value to write
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
True if all writes succeeded
|
|
518
|
+
|
|
519
|
+
Raises:
|
|
520
|
+
TransportWriteError: If any write operation fails
|
|
521
|
+
"""
|
|
522
|
+
# Sort parameters by address for efficient writing
|
|
523
|
+
sorted_params = sorted(parameters.items())
|
|
524
|
+
|
|
525
|
+
# Group consecutive addresses for batch writing
|
|
526
|
+
groups: list[tuple[int, list[int]]] = []
|
|
527
|
+
current_start: int | None = None
|
|
528
|
+
current_values: list[int] = []
|
|
529
|
+
|
|
530
|
+
for address, value in sorted_params:
|
|
531
|
+
if current_start is None:
|
|
532
|
+
current_start = address
|
|
533
|
+
current_values = [value]
|
|
534
|
+
elif address == current_start + len(current_values):
|
|
535
|
+
# Consecutive address, add to current group
|
|
536
|
+
current_values.append(value)
|
|
537
|
+
else:
|
|
538
|
+
# Non-consecutive, save current group and start new one
|
|
539
|
+
groups.append((current_start, current_values))
|
|
540
|
+
current_start = address
|
|
541
|
+
current_values = [value]
|
|
542
|
+
|
|
543
|
+
# Don't forget the last group
|
|
544
|
+
if current_start is not None and current_values:
|
|
545
|
+
groups.append((current_start, current_values))
|
|
546
|
+
|
|
547
|
+
# Write each group
|
|
548
|
+
for start_address, values in groups:
|
|
549
|
+
await self._write_holding_registers(start_address, values)
|
|
550
|
+
|
|
551
|
+
return True
|
|
552
|
+
|
|
553
|
+
async def read_serial_number(self) -> str:
|
|
554
|
+
"""Read inverter serial number from input registers 115-119.
|
|
555
|
+
|
|
556
|
+
The serial number is stored as 10 ASCII characters across 5 registers.
|
|
557
|
+
Each register contains 2 characters: low byte = char[0], high byte = char[1].
|
|
558
|
+
|
|
559
|
+
This can be used to:
|
|
560
|
+
- Validate the user-entered serial matches the actual device
|
|
561
|
+
- Auto-discover the serial during setup
|
|
562
|
+
- Detect cable swaps in multi-inverter setups
|
|
563
|
+
|
|
564
|
+
Returns:
|
|
565
|
+
10-character serial number string (e.g., "BA12345678")
|
|
566
|
+
|
|
567
|
+
Raises:
|
|
568
|
+
TransportReadError: If read operation fails
|
|
569
|
+
|
|
570
|
+
Example:
|
|
571
|
+
>>> transport = ModbusTransport(host="192.168.1.100", serial="")
|
|
572
|
+
>>> await transport.connect()
|
|
573
|
+
>>> actual_serial = await transport.read_serial_number()
|
|
574
|
+
>>> print(f"Connected to inverter: {actual_serial}")
|
|
575
|
+
"""
|
|
576
|
+
values = await self._read_input_registers(
|
|
577
|
+
SERIAL_NUMBER_START_REGISTER, SERIAL_NUMBER_REGISTER_COUNT
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
# Decode ASCII characters from register values
|
|
581
|
+
chars: list[str] = []
|
|
582
|
+
for value in values:
|
|
583
|
+
low_byte = value & 0xFF
|
|
584
|
+
high_byte = (value >> 8) & 0xFF
|
|
585
|
+
# Filter out non-printable characters
|
|
586
|
+
if 32 <= low_byte <= 126:
|
|
587
|
+
chars.append(chr(low_byte))
|
|
588
|
+
if 32 <= high_byte <= 126:
|
|
589
|
+
chars.append(chr(high_byte))
|
|
590
|
+
|
|
591
|
+
serial = "".join(chars)
|
|
592
|
+
_LOGGER.debug("Read serial number from Modbus: %s", serial)
|
|
593
|
+
return serial
|
|
594
|
+
|
|
595
|
+
async def validate_serial(self, expected_serial: str) -> bool:
|
|
596
|
+
"""Validate that the connected inverter matches the expected serial.
|
|
597
|
+
|
|
598
|
+
Args:
|
|
599
|
+
expected_serial: The serial number the user expects to connect to
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
True if serials match, False otherwise
|
|
603
|
+
|
|
604
|
+
Raises:
|
|
605
|
+
TransportReadError: If read operation fails
|
|
606
|
+
"""
|
|
607
|
+
actual_serial = await self.read_serial_number()
|
|
608
|
+
matches = actual_serial == expected_serial
|
|
609
|
+
|
|
610
|
+
if not matches:
|
|
611
|
+
_LOGGER.warning(
|
|
612
|
+
"Serial mismatch: expected %s, got %s",
|
|
613
|
+
expected_serial,
|
|
614
|
+
actual_serial,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
return matches
|