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.
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 +1427 -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 +364 -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 +708 -41
  34. pylxpweb/transports/__init__.py +78 -0
  35. pylxpweb/transports/capabilities.py +101 -0
  36. pylxpweb/transports/data.py +501 -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 +617 -0
  41. pylxpweb/transports/protocol.py +217 -0
  42. {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.2.dist-info}/METADATA +130 -85
  43. pylxpweb-0.5.2.dist-info/RECORD +52 -0
  44. {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.2.dist-info}/WHEEL +1 -1
  45. pylxpweb-0.5.2.dist-info/entry_points.txt +3 -0
  46. 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