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,126 @@
1
+ """Device and entity models for pylxpweb.
2
+
3
+ This module provides generic models for representing devices and their
4
+ entities (sensors, controls, etc.) in a platform-agnostic way.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+
14
+ class DeviceInfo(BaseModel):
15
+ """Device information model.
16
+
17
+ Represents a physical device with metadata and hierarchy information.
18
+
19
+ Example:
20
+ ```python
21
+ device_info = DeviceInfo(
22
+ identifiers={("pylxpweb", "inverter_1234567890")},
23
+ name="FlexBOSS21 Inverter",
24
+ manufacturer="EG4 Electronics",
25
+ model="FlexBOSS21",
26
+ sw_version="34",
27
+ via_device=("pylxpweb", "station_12345"),
28
+ )
29
+ ```
30
+ """
31
+
32
+ identifiers: set[tuple[str, str]] = Field(
33
+ description="Set of (domain, unique_id) tuples that identify this device"
34
+ )
35
+ name: str = Field(description="Human-readable device name")
36
+ manufacturer: str = Field(description="Device manufacturer")
37
+ model: str = Field(description="Device model name")
38
+ sw_version: str | None = Field(default=None, description="Software/firmware version")
39
+ via_device: tuple[str, str] | None = Field(
40
+ default=None, description="Parent device identifier (domain, unique_id)"
41
+ )
42
+ hw_version: str | None = Field(default=None, description="Hardware version")
43
+ configuration_url: str | None = Field(
44
+ default=None, description="URL to device configuration page"
45
+ )
46
+
47
+ model_config = {"frozen": False}
48
+
49
+
50
+ class Entity(BaseModel):
51
+ """Entity representation model.
52
+
53
+ Represents a single data point or control (sensor, switch, button, etc.)
54
+ from a device. Devices can have multiple entities.
55
+
56
+ Example:
57
+ ```python
58
+ entity = Entity(
59
+ unique_id="inverter_1234567890_pac",
60
+ name="Inverter 1234567890 AC Power",
61
+ device_class="power",
62
+ state_class="measurement",
63
+ unit_of_measurement="W",
64
+ value=1030.5,
65
+ attributes={"voltage": 240.0, "frequency": 60.0},
66
+ )
67
+ ```
68
+ """
69
+
70
+ unique_id: str = Field(description="Unique identifier for this entity")
71
+ name: str = Field(description="Human-readable entity name")
72
+ device_class: str | None = Field(
73
+ default=None, description="Device class (power, energy, temperature, etc.)"
74
+ )
75
+ state_class: str | None = Field(
76
+ default=None, description="State class (measurement, total, total_increasing)"
77
+ )
78
+ unit_of_measurement: str | None = Field(
79
+ default=None, description="Unit of measurement (W, kWh, %, °C, V, etc.)"
80
+ )
81
+ value: Any = Field(description="Current entity value/state")
82
+ attributes: dict[str, Any] = Field(
83
+ default_factory=dict, description="Additional entity attributes"
84
+ )
85
+ icon: str | None = Field(default=None, description="Icon identifier (e.g., mdi:solar-power)")
86
+ entity_category: str | None = Field(
87
+ default=None, description="Entity category (config, diagnostic)"
88
+ )
89
+
90
+ model_config = {"frozen": False}
91
+
92
+
93
+ # Standard device classes (platform-agnostic)
94
+ class DeviceClass:
95
+ """Standard device classes for entity categorization."""
96
+
97
+ # Sensor device classes
98
+ POWER = "power"
99
+ ENERGY = "energy"
100
+ BATTERY = "battery"
101
+ VOLTAGE = "voltage"
102
+ CURRENT = "current"
103
+ TEMPERATURE = "temperature"
104
+ FREQUENCY = "frequency"
105
+ POWER_FACTOR = "power_factor"
106
+
107
+ # Binary sensor device classes
108
+ CONNECTIVITY = "connectivity"
109
+ PROBLEM = "problem"
110
+
111
+
112
+ # Standard state classes
113
+ class StateClass:
114
+ """Standard state classes for entity state behavior."""
115
+
116
+ MEASUREMENT = "measurement"
117
+ TOTAL = "total"
118
+ TOTAL_INCREASING = "total_increasing"
119
+
120
+
121
+ # Standard entity categories
122
+ class EntityCategory:
123
+ """Standard entity categories for organization."""
124
+
125
+ CONFIG = "config"
126
+ DIAGNOSTIC = "diagnostic"
@@ -0,0 +1,351 @@
1
+ """ParallelGroup class for inverters in parallel operation.
2
+
3
+ This module provides the ParallelGroup class that represents a group of
4
+ inverters operating in parallel, optionally with a MID (GridBOSS) device.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ if TYPE_CHECKING:
12
+ from pylxpweb import LuxpowerClient
13
+ from pylxpweb.models import EnergyInfo
14
+
15
+ from .inverters.base import BaseInverter
16
+ from .mid_device import MIDDevice
17
+ from .station import Station
18
+
19
+
20
+ class ParallelGroup:
21
+ """Represents a group of inverters operating in parallel.
22
+
23
+ In the Luxpower/EG4 system, multiple inverters can operate in parallel
24
+ to increase total power capacity. The parallel group may include:
25
+ - Multiple inverters (2 or more)
26
+ - Optional MID device (GridBOSS) for grid management
27
+
28
+ Example:
29
+ ```python
30
+ # Access parallel groups from station
31
+ station = await client.get_station(plant_id)
32
+
33
+ for group in station.parallel_groups:
34
+ print(f"Group {group.name}: {len(group.inverters)} inverters")
35
+
36
+ if group.mid_device:
37
+ print(f" GridBOSS: {group.mid_device.serial_number}")
38
+
39
+ for inverter in group.inverters:
40
+ await inverter.refresh()
41
+ print(f" Inverter {inverter.serial_number}: {inverter.ac_output_power}W")
42
+ ```
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ client: LuxpowerClient,
48
+ station: Station,
49
+ name: str,
50
+ first_device_serial: str,
51
+ ) -> None:
52
+ """Initialize parallel group.
53
+
54
+ Args:
55
+ client: LuxpowerClient instance for API access
56
+ station: Parent station object
57
+ name: Group identifier (typically "A", "B", etc.)
58
+ first_device_serial: Serial number of first device in group
59
+ """
60
+ self._client = client
61
+ self.station = station
62
+ self.name = name
63
+ self.first_device_serial = first_device_serial
64
+
65
+ # Device collections (loaded by factory methods)
66
+ self.inverters: list[BaseInverter] = []
67
+ self.mid_device: MIDDevice | None = None
68
+
69
+ # Energy data (private - use properties for access)
70
+ self._energy: EnergyInfo | None = None
71
+
72
+ async def refresh(self) -> None:
73
+ """Refresh runtime data for all devices in group.
74
+
75
+ This refreshes:
76
+ - All inverters in the group
77
+ - MID device if present
78
+ - Parallel group energy data
79
+ """
80
+ import asyncio
81
+
82
+ tasks = []
83
+
84
+ # Refresh all inverters (all inverters have refresh method)
85
+ for inverter in self.inverters:
86
+ tasks.append(inverter.refresh())
87
+
88
+ # Refresh MID device (check for None, mid_device always has refresh method)
89
+ if self.mid_device:
90
+ tasks.append(self.mid_device.refresh())
91
+
92
+ # Fetch parallel group energy data if we have inverters
93
+ if self.inverters:
94
+ first_serial = self.inverters[0].serial_number
95
+ tasks.append(self._fetch_energy_data(first_serial))
96
+
97
+ # Execute concurrently
98
+ if tasks:
99
+ await asyncio.gather(*tasks, return_exceptions=True)
100
+
101
+ async def _fetch_energy_data(self, serial_number: str) -> None:
102
+ """Fetch parallel group energy data.
103
+
104
+ Args:
105
+ serial_number: Serial number of first inverter in group.
106
+ """
107
+ from contextlib import suppress
108
+
109
+ from pylxpweb.exceptions import LuxpowerAPIError, LuxpowerConnectionError
110
+
111
+ # Keep existing cached data on error
112
+ with suppress(LuxpowerAPIError, LuxpowerConnectionError):
113
+ self._energy = await self._client.api.devices.get_parallel_energy(serial_number)
114
+
115
+ async def get_combined_energy(self) -> dict[str, float]:
116
+ """Get combined energy statistics for all inverters in group.
117
+
118
+ Uses the parallel group energy endpoint which returns aggregate data
119
+ for the entire parallel group instead of summing individual inverters.
120
+
121
+ Returns:
122
+ Dictionary with 'today_kwh' and 'lifetime_kwh' totals.
123
+
124
+ Raises:
125
+ ValueError: If no inverters in the group to query
126
+ """
127
+ if not self.inverters:
128
+ return {
129
+ "today_kwh": 0.0,
130
+ "lifetime_kwh": 0.0,
131
+ }
132
+
133
+ # Use first inverter serial to query parallel group energy
134
+ # The API returns aggregate data for the entire group
135
+ first_serial = self.inverters[0].serial_number
136
+ energy_info = await self._client.api.devices.get_parallel_energy(first_serial)
137
+
138
+ # Energy values are in units of 0.1 kWh, divide by 10 for kWh
139
+ return {
140
+ "today_kwh": energy_info.todayYielding / 10,
141
+ "lifetime_kwh": energy_info.totalYielding / 10,
142
+ }
143
+
144
+ # ===========================================
145
+ # Energy Properties - Today
146
+ # ===========================================
147
+ # Daily energy values reset at midnight (API server time).
148
+ # The client automatically invalidates cache on hour boundaries
149
+ # to minimize stale data, but cannot control API reset timing.
150
+
151
+ @property
152
+ def today_yielding(self) -> float:
153
+ """Get today's PV generation in kWh.
154
+
155
+ This value resets daily at midnight (API-controlled timing).
156
+ The client invalidates cache on hour boundaries, but values
157
+ shortly after midnight may reflect stale API data.
158
+
159
+ For Home Assistant: Use SensorStateClass.TOTAL_INCREASING
160
+ to let HA's statistics handle resets automatically.
161
+
162
+ Returns:
163
+ Today's yielding (÷10 for kWh), or 0.0 if no data.
164
+ """
165
+ if self._energy is None:
166
+ return 0.0
167
+ from pylxpweb.constants import scale_energy_value
168
+
169
+ return scale_energy_value("todayYielding", self._energy.todayYielding, to_kwh=True)
170
+
171
+ @property
172
+ def today_charging(self) -> float:
173
+ """Get today's battery charging energy in kWh.
174
+
175
+ Returns:
176
+ Today's charging (÷10 for kWh), or 0.0 if no data.
177
+ """
178
+ if self._energy is None:
179
+ return 0.0
180
+ from pylxpweb.constants import scale_energy_value
181
+
182
+ return scale_energy_value("todayCharging", self._energy.todayCharging, to_kwh=True)
183
+
184
+ @property
185
+ def today_discharging(self) -> float:
186
+ """Get today's battery discharging energy in kWh.
187
+
188
+ Returns:
189
+ Today's discharging (÷10 for kWh), or 0.0 if no data.
190
+ """
191
+ if self._energy is None:
192
+ return 0.0
193
+ from pylxpweb.constants import scale_energy_value
194
+
195
+ return scale_energy_value("todayDischarging", self._energy.todayDischarging, to_kwh=True)
196
+
197
+ @property
198
+ def today_import(self) -> float:
199
+ """Get today's grid import energy in kWh.
200
+
201
+ Returns:
202
+ Today's import (÷10 for kWh), or 0.0 if no data.
203
+ """
204
+ if self._energy is None:
205
+ return 0.0
206
+ from pylxpweb.constants import scale_energy_value
207
+
208
+ return scale_energy_value("todayImport", self._energy.todayImport, to_kwh=True)
209
+
210
+ @property
211
+ def today_export(self) -> float:
212
+ """Get today's grid export energy in kWh.
213
+
214
+ Returns:
215
+ Today's export (÷10 for kWh), or 0.0 if no data.
216
+ """
217
+ if self._energy is None:
218
+ return 0.0
219
+ from pylxpweb.constants import scale_energy_value
220
+
221
+ return scale_energy_value("todayExport", self._energy.todayExport, to_kwh=True)
222
+
223
+ @property
224
+ def today_usage(self) -> float:
225
+ """Get today's energy usage in kWh.
226
+
227
+ Returns:
228
+ Today's usage (÷10 for kWh), or 0.0 if no data.
229
+ """
230
+ if self._energy is None:
231
+ return 0.0
232
+ from pylxpweb.constants import scale_energy_value
233
+
234
+ return scale_energy_value("todayUsage", self._energy.todayUsage, to_kwh=True)
235
+
236
+ # ===========================================
237
+ # Energy Properties - Total (Lifetime)
238
+ # ===========================================
239
+
240
+ @property
241
+ def total_yielding(self) -> float:
242
+ """Get total lifetime PV generation in kWh.
243
+
244
+ Returns:
245
+ Total yielding (÷10 for kWh), or 0.0 if no data.
246
+ """
247
+ if self._energy is None:
248
+ return 0.0
249
+ from pylxpweb.constants import scale_energy_value
250
+
251
+ return scale_energy_value("totalYielding", self._energy.totalYielding, to_kwh=True)
252
+
253
+ @property
254
+ def total_charging(self) -> float:
255
+ """Get total lifetime battery charging energy in kWh.
256
+
257
+ Returns:
258
+ Total charging (÷10 for kWh), or 0.0 if no data.
259
+ """
260
+ if self._energy is None:
261
+ return 0.0
262
+ from pylxpweb.constants import scale_energy_value
263
+
264
+ return scale_energy_value("totalCharging", self._energy.totalCharging, to_kwh=True)
265
+
266
+ @property
267
+ def total_discharging(self) -> float:
268
+ """Get total lifetime battery discharging energy in kWh.
269
+
270
+ Returns:
271
+ Total discharging (÷10 for kWh), or 0.0 if no data.
272
+ """
273
+ if self._energy is None:
274
+ return 0.0
275
+ from pylxpweb.constants import scale_energy_value
276
+
277
+ return scale_energy_value("totalDischarging", self._energy.totalDischarging, to_kwh=True)
278
+
279
+ @property
280
+ def total_import(self) -> float:
281
+ """Get total lifetime grid import energy in kWh.
282
+
283
+ Returns:
284
+ Total import (÷10 for kWh), or 0.0 if no data.
285
+ """
286
+ if self._energy is None:
287
+ return 0.0
288
+ from pylxpweb.constants import scale_energy_value
289
+
290
+ return scale_energy_value("totalImport", self._energy.totalImport, to_kwh=True)
291
+
292
+ @property
293
+ def total_export(self) -> float:
294
+ """Get total lifetime grid export energy in kWh.
295
+
296
+ Returns:
297
+ Total export (÷10 for kWh), or 0.0 if no data.
298
+ """
299
+ if self._energy is None:
300
+ return 0.0
301
+ from pylxpweb.constants import scale_energy_value
302
+
303
+ return scale_energy_value("totalExport", self._energy.totalExport, to_kwh=True)
304
+
305
+ @property
306
+ def total_usage(self) -> float:
307
+ """Get total lifetime energy usage in kWh.
308
+
309
+ Returns:
310
+ Total usage (÷10 for kWh), or 0.0 if no data.
311
+ """
312
+ if self._energy is None:
313
+ return 0.0
314
+ from pylxpweb.constants import scale_energy_value
315
+
316
+ return scale_energy_value("totalUsage", self._energy.totalUsage, to_kwh=True)
317
+
318
+ @classmethod
319
+ async def from_api_data(
320
+ cls,
321
+ client: LuxpowerClient,
322
+ station: Station,
323
+ group_data: dict[str, Any],
324
+ ) -> ParallelGroup:
325
+ """Factory method to create ParallelGroup from API data.
326
+
327
+ Args:
328
+ client: LuxpowerClient instance
329
+ station: Parent station object
330
+ group_data: API response data for parallel group
331
+
332
+ Returns:
333
+ ParallelGroup instance with devices loaded.
334
+ """
335
+ # Extract group info
336
+ name = group_data.get("parallelGroup", "A")
337
+ first_serial = group_data.get("parallelFirstDeviceSn", "")
338
+
339
+ # Create group
340
+ group = cls(
341
+ client=client,
342
+ station=station,
343
+ name=name,
344
+ first_device_serial=first_serial,
345
+ )
346
+
347
+ # Note: Inverters and MID device will be loaded by Station._load_devices()
348
+ # This is because device creation requires model-specific inverter classes
349
+ # which will be implemented in Phase 2
350
+
351
+ return group