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