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,59 @@
1
+ """Transport-specific exceptions.
2
+
3
+ This module provides exception classes for transport operations,
4
+ allowing clients to handle errors appropriately.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class TransportError(Exception):
11
+ """Base exception for all transport errors."""
12
+
13
+ pass
14
+
15
+
16
+ class TransportConnectionError(TransportError):
17
+ """Failed to connect to the device."""
18
+
19
+ pass
20
+
21
+
22
+ class TransportTimeoutError(TransportError):
23
+ """Operation timed out."""
24
+
25
+ pass
26
+
27
+
28
+ class TransportReadError(TransportError):
29
+ """Failed to read data from device."""
30
+
31
+ pass
32
+
33
+
34
+ class TransportWriteError(TransportError):
35
+ """Failed to write data to device."""
36
+
37
+ pass
38
+
39
+
40
+ class UnsupportedOperationError(TransportError):
41
+ """Operation not supported by this transport.
42
+
43
+ Raised when attempting an operation that the transport
44
+ doesn't support (e.g., reading history via Modbus).
45
+ """
46
+
47
+ def __init__(self, operation: str, transport_type: str) -> None:
48
+ """Initialize with operation and transport details.
49
+
50
+ Args:
51
+ operation: The operation that was attempted
52
+ transport_type: The type of transport that doesn't support it
53
+ """
54
+ self.operation = operation
55
+ self.transport_type = transport_type
56
+ super().__init__(
57
+ f"Operation '{operation}' is not supported by {transport_type} transport. "
58
+ "Use HTTP transport for this feature."
59
+ )
@@ -0,0 +1,119 @@
1
+ """Factory functions for creating transport instances.
2
+
3
+ This module provides convenience functions to create transport instances
4
+ for communicating with Luxpower/EG4 inverters via different protocols.
5
+
6
+ Example:
7
+ # HTTP Transport (cloud API)
8
+ async with LuxpowerClient(username, password) as client:
9
+ transport = create_http_transport(client, serial="CE12345678")
10
+ await transport.connect()
11
+ runtime = await transport.read_runtime()
12
+
13
+ # Modbus Transport (local network)
14
+ transport = create_modbus_transport(
15
+ host="192.168.1.100",
16
+ serial="CE12345678",
17
+ )
18
+ async with transport:
19
+ runtime = await transport.read_runtime()
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import TYPE_CHECKING
25
+
26
+ from .http import HTTPTransport
27
+ from .modbus import ModbusTransport
28
+
29
+ if TYPE_CHECKING:
30
+ from pylxpweb import LuxpowerClient
31
+
32
+
33
+ def create_http_transport(
34
+ client: LuxpowerClient,
35
+ serial: str,
36
+ ) -> HTTPTransport:
37
+ """Create an HTTP transport using the cloud API.
38
+
39
+ Args:
40
+ client: Authenticated LuxpowerClient instance
41
+ serial: Inverter serial number
42
+
43
+ Returns:
44
+ HTTPTransport instance ready for use
45
+
46
+ Example:
47
+ async with LuxpowerClient(username, password) as client:
48
+ transport = create_http_transport(client, "CE12345678")
49
+ await transport.connect()
50
+
51
+ runtime = await transport.read_runtime()
52
+ print(f"PV Power: {runtime.pv_total_power}W")
53
+ print(f"Battery SOC: {runtime.battery_soc}%")
54
+
55
+ energy = await transport.read_energy()
56
+ print(f"Today's yield: {energy.pv_energy_today} kWh")
57
+ """
58
+ return HTTPTransport(client, serial)
59
+
60
+
61
+ def create_modbus_transport(
62
+ host: str,
63
+ serial: str,
64
+ *,
65
+ port: int = 502,
66
+ unit_id: int = 1,
67
+ timeout: float = 10.0,
68
+ ) -> ModbusTransport:
69
+ """Create a Modbus TCP transport for local network communication.
70
+
71
+ This allows direct communication with the inverter over the local network
72
+ without requiring cloud connectivity.
73
+
74
+ Args:
75
+ host: Inverter IP address or hostname
76
+ serial: Inverter serial number (for identification)
77
+ port: Modbus TCP port (default: 502)
78
+ unit_id: Modbus unit/slave ID (default: 1)
79
+ timeout: Operation timeout in seconds (default: 10.0)
80
+
81
+ Returns:
82
+ ModbusTransport instance ready for use
83
+
84
+ Example:
85
+ transport = create_modbus_transport(
86
+ host="192.168.1.100",
87
+ serial="CE12345678",
88
+ )
89
+
90
+ async with transport:
91
+ runtime = await transport.read_runtime()
92
+ print(f"PV Power: {runtime.pv_total_power}W")
93
+
94
+ battery = await transport.read_battery()
95
+ if battery:
96
+ print(f"Battery SOC: {battery.soc}%")
97
+
98
+ Note:
99
+ Modbus communication requires:
100
+ - Network access to the inverter
101
+ - Modbus TCP enabled on the inverter (check inverter settings)
102
+ - No firewall blocking port 502
103
+
104
+ The inverter must have a datalogger/dongle that supports Modbus TCP,
105
+ or direct Modbus TCP capability (varies by model).
106
+ """
107
+ return ModbusTransport(
108
+ host=host,
109
+ serial=serial,
110
+ port=port,
111
+ unit_id=unit_id,
112
+ timeout=timeout,
113
+ )
114
+
115
+
116
+ __all__ = [
117
+ "create_http_transport",
118
+ "create_modbus_transport",
119
+ ]
@@ -0,0 +1,329 @@
1
+ """HTTP transport implementation.
2
+
3
+ This module provides the HTTPTransport class that wraps the existing
4
+ LuxpowerClient for cloud API communication.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import TYPE_CHECKING
11
+
12
+ from pylxpweb.exceptions import (
13
+ LuxpowerAPIError,
14
+ LuxpowerAuthError,
15
+ LuxpowerConnectionError,
16
+ LuxpowerDeviceError,
17
+ )
18
+
19
+ from .capabilities import HTTP_CAPABILITIES, TransportCapabilities
20
+ from .data import BatteryBankData, BatteryData, InverterEnergyData, InverterRuntimeData
21
+ from .exceptions import (
22
+ TransportConnectionError,
23
+ TransportReadError,
24
+ TransportTimeoutError,
25
+ TransportWriteError,
26
+ )
27
+ from .protocol import BaseTransport
28
+
29
+ if TYPE_CHECKING:
30
+ from pylxpweb import LuxpowerClient
31
+
32
+ _LOGGER = logging.getLogger(__name__)
33
+
34
+
35
+ class HTTPTransport(BaseTransport):
36
+ """HTTP transport using cloud API via LuxpowerClient.
37
+
38
+ This transport wraps the existing LuxpowerClient to provide
39
+ the standard InverterTransport interface.
40
+
41
+ Example:
42
+ async with LuxpowerClient(username, password) as client:
43
+ transport = HTTPTransport(client, serial="CE12345678")
44
+ await transport.connect()
45
+
46
+ runtime = await transport.read_runtime()
47
+ print(f"PV Power: {runtime.pv_total_power}W")
48
+ """
49
+
50
+ def __init__(self, client: LuxpowerClient, serial: str) -> None:
51
+ """Initialize HTTP transport.
52
+
53
+ Args:
54
+ client: Connected LuxpowerClient instance
55
+ serial: Inverter serial number
56
+ """
57
+ super().__init__(serial)
58
+ self._client = client
59
+
60
+ @property
61
+ def capabilities(self) -> TransportCapabilities:
62
+ """Get HTTP transport capabilities."""
63
+ return HTTP_CAPABILITIES
64
+
65
+ async def connect(self) -> None:
66
+ """Verify connection to cloud API.
67
+
68
+ The LuxpowerClient handles actual authentication.
69
+ This method ensures the client session is valid.
70
+
71
+ Raises:
72
+ TransportConnectionError: If client not authenticated
73
+ """
74
+ try:
75
+ # Ensure client is authenticated - login() handles session management
76
+ await self._client.login()
77
+ except LuxpowerAuthError as err:
78
+ _LOGGER.error("Authentication failed for %s: %s", self._serial, err)
79
+ raise TransportConnectionError(f"Authentication failed for cloud API: {err}") from err
80
+ except (TimeoutError, LuxpowerConnectionError, OSError) as err:
81
+ _LOGGER.error("Connection failed for %s: %s", self._serial, err)
82
+ raise TransportConnectionError(f"Failed to connect to cloud API: {err}") from err
83
+
84
+ self._connected = True
85
+ _LOGGER.debug("HTTP transport connected for %s", self._serial)
86
+
87
+ async def disconnect(self) -> None:
88
+ """Mark transport as disconnected.
89
+
90
+ Note: Does not close the LuxpowerClient session, as it may be
91
+ shared across multiple transports.
92
+ """
93
+ self._connected = False
94
+ _LOGGER.debug("HTTP transport disconnected for %s", self._serial)
95
+
96
+ async def read_runtime(self) -> InverterRuntimeData:
97
+ """Read runtime data via HTTP API.
98
+
99
+ Returns:
100
+ Runtime data with all values properly scaled
101
+
102
+ Raises:
103
+ TransportReadError: If API call fails
104
+ TransportTimeoutError: If request times out
105
+ """
106
+ self._ensure_connected()
107
+
108
+ try:
109
+ runtime = await self._client.api.devices.get_inverter_runtime(self._serial)
110
+ return InverterRuntimeData.from_http_response(runtime)
111
+ except TimeoutError as err:
112
+ _LOGGER.error("Timeout reading runtime data for %s", self._serial)
113
+ raise TransportTimeoutError(f"Timeout reading runtime data for {self._serial}") from err
114
+ except (LuxpowerAPIError, LuxpowerDeviceError, LuxpowerConnectionError) as err:
115
+ _LOGGER.error("Failed to read runtime data for %s: %s", self._serial, err)
116
+ raise TransportReadError(
117
+ f"Failed to read runtime data for {self._serial}: {err}"
118
+ ) from err
119
+
120
+ async def read_energy(self) -> InverterEnergyData:
121
+ """Read energy statistics via HTTP API.
122
+
123
+ Returns:
124
+ Energy data with all values in kWh
125
+
126
+ Raises:
127
+ TransportReadError: If API call fails
128
+ TransportTimeoutError: If request times out
129
+ """
130
+ self._ensure_connected()
131
+
132
+ try:
133
+ energy = await self._client.api.devices.get_inverter_energy(self._serial)
134
+ return InverterEnergyData.from_http_response(energy)
135
+ except TimeoutError as err:
136
+ _LOGGER.error("Timeout reading energy data for %s", self._serial)
137
+ raise TransportTimeoutError(f"Timeout reading energy data for {self._serial}") from err
138
+ except (LuxpowerAPIError, LuxpowerDeviceError, LuxpowerConnectionError) as err:
139
+ _LOGGER.error("Failed to read energy data for %s: %s", self._serial, err)
140
+ raise TransportReadError(
141
+ f"Failed to read energy data for {self._serial}: {err}"
142
+ ) from err
143
+
144
+ async def read_battery(self) -> BatteryBankData | None:
145
+ """Read battery information via HTTP API.
146
+
147
+ Returns:
148
+ Battery bank data if batteries present, None otherwise
149
+
150
+ Raises:
151
+ TransportReadError: If API call fails
152
+ """
153
+ self._ensure_connected()
154
+
155
+ try:
156
+ battery_info = await self._client.api.devices.get_battery_info(self._serial)
157
+
158
+ if battery_info is None:
159
+ return None
160
+
161
+ # Import scaling here to avoid circular imports
162
+ from pylxpweb.constants.scaling import ScaleFactor, apply_scale
163
+
164
+ # Build individual battery data from batteryArray
165
+ batteries: list[BatteryData] = []
166
+ if battery_info.batteryArray:
167
+ for bat in battery_info.batteryArray:
168
+ # BatteryModule fields: batIndex, batterySn, totalVoltage, current, etc.
169
+ batteries.append(
170
+ BatteryData(
171
+ battery_index=bat.batIndex,
172
+ serial_number=bat.batterySn or "",
173
+ # totalVoltage needs /100 scaling
174
+ voltage=apply_scale(bat.totalVoltage, ScaleFactor.SCALE_100),
175
+ # current needs /10 scaling (not /100!)
176
+ current=apply_scale(bat.current, ScaleFactor.SCALE_10),
177
+ soc=bat.soc or 0,
178
+ soh=bat.soh or 100,
179
+ # Temperatures: batMaxCellTemp/batMinCellTemp are /10
180
+ temperature=apply_scale(bat.batMaxCellTemp, ScaleFactor.SCALE_10),
181
+ max_capacity=float(bat.currentFullCapacity or 0),
182
+ current_capacity=float(bat.currentRemainCapacity or 0),
183
+ cycle_count=bat.cycleCnt or 0,
184
+ # Cell voltage extremes: /1000 scaling
185
+ min_cell_voltage=apply_scale(
186
+ bat.batMinCellVoltage, ScaleFactor.SCALE_1000
187
+ ),
188
+ max_cell_voltage=apply_scale(
189
+ bat.batMaxCellVoltage, ScaleFactor.SCALE_1000
190
+ ),
191
+ # BatteryModule doesn't have status/fault/warning codes
192
+ )
193
+ )
194
+
195
+ # Build aggregate bank data from BatteryInfo header
196
+ # BatteryInfo fields: vBat (/10), soc, pCharge, pDisCharge, etc.
197
+ return BatteryBankData(
198
+ voltage=apply_scale(battery_info.vBat, ScaleFactor.SCALE_10),
199
+ soc=battery_info.soc or 0,
200
+ charge_power=float(battery_info.pCharge or 0),
201
+ discharge_power=float(battery_info.pDisCharge or 0),
202
+ max_capacity=float(battery_info.maxBatteryCharge or 0),
203
+ current_capacity=float(battery_info.currentBatteryCharge or 0),
204
+ battery_count=battery_info.totalNumber or len(batteries),
205
+ batteries=batteries,
206
+ # Note: BatteryInfo doesn't have soh, temperature, current, status codes
207
+ )
208
+
209
+ except TimeoutError as err:
210
+ _LOGGER.error("Timeout reading battery data for %s", self._serial)
211
+ raise TransportTimeoutError(f"Timeout reading battery data for {self._serial}") from err
212
+ except (LuxpowerAPIError, LuxpowerDeviceError, LuxpowerConnectionError) as err:
213
+ _LOGGER.error("Failed to read battery data for %s: %s", self._serial, err)
214
+ raise TransportReadError(
215
+ f"Failed to read battery data for {self._serial}: {err}"
216
+ ) from err
217
+
218
+ async def read_parameters(
219
+ self,
220
+ start_address: int,
221
+ count: int,
222
+ ) -> dict[int, int]:
223
+ """Read configuration parameters via HTTP API.
224
+
225
+ Args:
226
+ start_address: Starting register address
227
+ count: Number of registers to read (max 127)
228
+
229
+ Returns:
230
+ Dict mapping register address to raw integer value
231
+
232
+ Raises:
233
+ TransportReadError: If API call fails
234
+ TransportTimeoutError: If request times out
235
+ """
236
+ self._ensure_connected()
237
+
238
+ try:
239
+ response = await self._client.api.control.read_parameters(
240
+ self._serial,
241
+ start_register=start_address,
242
+ point_number=min(count, 127), # API limit
243
+ )
244
+
245
+ # ParameterReadResponse has a .parameters property that extracts
246
+ # the register values as a dict (excluding metadata fields)
247
+ params = response.parameters
248
+
249
+ # Convert response to address -> value dict
250
+ # The API returns parameter names, we need to map back to addresses
251
+ result: dict[int, int] = {}
252
+ for key, value in params.items():
253
+ try:
254
+ # Try to extract register address from various formats
255
+ if isinstance(value, int):
256
+ # Value is the register value, key might be the address
257
+ if key.isdigit():
258
+ result[int(key)] = value
259
+ else:
260
+ # Key is parameter name, need reverse lookup
261
+ # For now, skip named parameters (they need mapping)
262
+ _LOGGER.debug(
263
+ "Skipping named parameter %s=%s (address lookup not implemented)",
264
+ key,
265
+ value,
266
+ )
267
+ elif isinstance(value, (str, float)) and key.isdigit():
268
+ result[int(key)] = int(float(value))
269
+ else:
270
+ _LOGGER.debug(
271
+ "Skipping parameter with unexpected format: key=%s, value=%s (type=%s)",
272
+ key,
273
+ value,
274
+ type(value).__name__,
275
+ )
276
+ except (ValueError, TypeError) as err:
277
+ _LOGGER.warning(
278
+ "Failed to parse parameter %s=%s: %s",
279
+ key,
280
+ value,
281
+ err,
282
+ )
283
+ continue
284
+
285
+ return result
286
+
287
+ except TimeoutError as err:
288
+ _LOGGER.error("Timeout reading parameters for %s", self._serial)
289
+ raise TransportTimeoutError(f"Timeout reading parameters for {self._serial}") from err
290
+ except (LuxpowerAPIError, LuxpowerDeviceError, LuxpowerConnectionError) as err:
291
+ _LOGGER.error("Failed to read parameters for %s: %s", self._serial, err)
292
+ raise TransportReadError(
293
+ f"Failed to read parameters for {self._serial}: {err}"
294
+ ) from err
295
+
296
+ async def write_parameters(
297
+ self,
298
+ parameters: dict[int, int],
299
+ ) -> bool:
300
+ """Write configuration parameters via HTTP API.
301
+
302
+ Args:
303
+ parameters: Dict mapping register address to value to write
304
+
305
+ Returns:
306
+ True if write succeeded
307
+
308
+ Raises:
309
+ TransportWriteError: If API call fails
310
+ TransportTimeoutError: If request times out
311
+ """
312
+ self._ensure_connected()
313
+
314
+ try:
315
+ # Use the batch write_parameters method which takes dict[int, int]
316
+ await self._client.api.control.write_parameters(
317
+ self._serial,
318
+ parameters=parameters,
319
+ )
320
+ return True
321
+
322
+ except TimeoutError as err:
323
+ _LOGGER.error("Timeout writing parameters for %s", self._serial)
324
+ raise TransportTimeoutError(f"Timeout writing parameters for {self._serial}") from err
325
+ except (LuxpowerAPIError, LuxpowerDeviceError, LuxpowerConnectionError) as err:
326
+ _LOGGER.error("Failed to write parameters for %s: %s", self._serial, err)
327
+ raise TransportWriteError(
328
+ f"Failed to write parameters for {self._serial}: {err}"
329
+ ) from err