pylxpweb 0.1.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.
@@ -0,0 +1,470 @@
1
+ """Plant/Station endpoints for the Luxpower API.
2
+
3
+ This module provides plant/station functionality including:
4
+ - Plant discovery and listing
5
+ - Plant configuration management
6
+ - Daylight Saving Time control
7
+ - Plant overview with real-time metrics
8
+ - Inverter overview across plants
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from pylxpweb.endpoints.base import BaseEndpoint
16
+ from pylxpweb.models import PlantListResponse
17
+
18
+ if TYPE_CHECKING:
19
+ from pylxpweb.client import LuxpowerClient
20
+
21
+
22
+ class PlantEndpoints(BaseEndpoint):
23
+ """Plant/Station endpoints for discovery, configuration, and overview."""
24
+
25
+ def __init__(self, client: LuxpowerClient) -> None:
26
+ """Initialize plant endpoints.
27
+
28
+ Args:
29
+ client: The parent LuxpowerClient instance
30
+ """
31
+ super().__init__(client)
32
+
33
+ async def get_plants(
34
+ self,
35
+ *,
36
+ sort: str = "createDate",
37
+ order: str = "desc",
38
+ search_text: str = "",
39
+ page: int = 1,
40
+ rows: int = 20,
41
+ ) -> PlantListResponse:
42
+ """Get list of available plants/stations.
43
+
44
+ Args:
45
+ sort: Sort field (default: createDate)
46
+ order: Sort order (asc/desc, default: desc)
47
+ search_text: Search filter text
48
+ page: Page number for pagination
49
+ rows: Number of rows per page
50
+
51
+ Returns:
52
+ PlantListResponse: List of plants with metadata
53
+
54
+ Example:
55
+ plants = await client.plants.get_plants()
56
+ for plant in plants.rows:
57
+ print(f"Plant: {plant.name}, ID: {plant.plantId}")
58
+ """
59
+ await self.client._ensure_authenticated()
60
+
61
+ data = {
62
+ "sort": sort,
63
+ "order": order,
64
+ "searchText": search_text,
65
+ "page": page,
66
+ "rows": rows,
67
+ }
68
+
69
+ response = await self.client._request(
70
+ "POST", "/WManage/web/config/plant/list/viewer", data=data
71
+ )
72
+ return PlantListResponse.model_validate(response)
73
+
74
+ async def get_plant_details(self, plant_id: int | str) -> dict[str, Any]:
75
+ """Get detailed plant/station configuration information.
76
+
77
+ Args:
78
+ plant_id: The plant/station ID
79
+
80
+ Returns:
81
+ Dict containing plant configuration including:
82
+ - plantId: Plant identifier
83
+ - name: Station name
84
+ - nominalPower: Solar PV power rating (W)
85
+ - timezone: Timezone string (e.g., "GMT -8")
86
+ - daylightSavingTime: DST enabled (boolean)
87
+ - continent: Continent enum value
88
+ - region: Region enum value
89
+ - country: Country enum value
90
+ - longitude: Geographic coordinate
91
+ - latitude: Geographic coordinate
92
+ - createDate: Plant creation date
93
+ - address: Physical address
94
+
95
+ Raises:
96
+ LuxpowerAPIError: If plant not found or API error occurs
97
+
98
+ Example:
99
+ details = await client.plants.get_plant_details("12345")
100
+ print(f"Plant: {details['name']}")
101
+ print(f"Timezone: {details['timezone']}")
102
+ print(f"DST Enabled: {details['daylightSavingTime']}")
103
+ """
104
+ await self.client._ensure_authenticated()
105
+
106
+ data = {
107
+ "page": 1,
108
+ "rows": 20,
109
+ "searchText": "",
110
+ "targetPlantId": str(plant_id),
111
+ "sort": "createDate",
112
+ "order": "desc",
113
+ }
114
+
115
+ response = await self.client._request(
116
+ "POST", "/WManage/web/config/plant/list/viewer", data=data
117
+ )
118
+
119
+ if isinstance(response, dict) and response.get("rows"):
120
+ from logging import getLogger
121
+
122
+ plant_data = response["rows"][0]
123
+ getLogger(__name__).debug(
124
+ "Retrieved plant details for plant %s: %s",
125
+ plant_id,
126
+ plant_data.get("name"),
127
+ )
128
+ return dict(plant_data)
129
+
130
+ from pylxpweb.exceptions import LuxpowerAPIError
131
+
132
+ raise LuxpowerAPIError(f"Plant {plant_id} not found in API response")
133
+
134
+ async def _fetch_country_location_from_api(self, country_human: str) -> tuple[str, str]:
135
+ """Fetch continent and region for a country from locale API.
136
+
137
+ This is the fallback method when country is not in static mapping.
138
+ Queries the locale API to discover the continent and region dynamically.
139
+
140
+ Args:
141
+ country_human: Human-readable country name from API
142
+
143
+ Returns:
144
+ Tuple of (continent_enum, region_enum)
145
+
146
+ Raises:
147
+ LuxpowerAPIError: If country cannot be found in locale API
148
+ """
149
+ import json
150
+ from logging import getLogger
151
+
152
+ _LOGGER = getLogger(__name__)
153
+ _LOGGER.info(
154
+ "Country '%s' not in static mapping, fetching from locale API",
155
+ country_human,
156
+ )
157
+
158
+ # Get all continents from constants
159
+ from pylxpweb.constants import CONTINENT_MAP
160
+
161
+ session = await self.client._get_session()
162
+
163
+ # Search through all continents and regions
164
+ for continent_enum in CONTINENT_MAP.values():
165
+ # Get regions for this continent
166
+ async with session.post(
167
+ f"{self.client.base_url}/WManage/locale/region",
168
+ data=f"continent={continent_enum}",
169
+ headers={"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"},
170
+ ) as resp:
171
+ if resp.status != 200:
172
+ continue
173
+ regions_text = await resp.text()
174
+ regions = json.loads(regions_text)
175
+
176
+ # Check each region for our country
177
+ for region in regions:
178
+ region_value = region["value"]
179
+
180
+ async with session.post(
181
+ f"{self.client.base_url}/WManage/locale/country",
182
+ data=f"region={region_value}",
183
+ headers={"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"},
184
+ ) as resp:
185
+ if resp.status != 200:
186
+ continue
187
+ countries_text = await resp.text()
188
+ countries = json.loads(countries_text)
189
+
190
+ # Check if our country is in this region
191
+ for country in countries:
192
+ if country["text"] == country_human:
193
+ _LOGGER.info(
194
+ "Found country '%s' in locale API: continent=%s, region=%s",
195
+ country_human,
196
+ continent_enum,
197
+ region_value,
198
+ )
199
+ return (continent_enum, region_value)
200
+
201
+ # Country not found anywhere
202
+ from pylxpweb.exceptions import LuxpowerAPIError
203
+
204
+ raise LuxpowerAPIError(
205
+ f"Country '{country_human}' not found in locale API. "
206
+ "This country may not be supported by the Luxpower platform."
207
+ )
208
+
209
+ async def _prepare_plant_update_data(
210
+ self, plant_details: dict[str, Any], **overrides: Any
211
+ ) -> dict[str, Any]:
212
+ """Prepare data for plant configuration update POST request.
213
+
214
+ Converts API response values to the enum format required by the POST endpoint.
215
+ Uses hybrid approach: static mapping for common countries (fast), dynamic
216
+ fetching from locale API for unknown countries (comprehensive).
217
+
218
+ Args:
219
+ plant_details: Plant details from get_plant_details()
220
+ **overrides: Fields to override (e.g., daylightSavingTime=True)
221
+
222
+ Returns:
223
+ Dictionary ready for POST to /WManage/web/config/plant/edit
224
+
225
+ Raises:
226
+ ValueError: If unable to map required fields
227
+ LuxpowerAPIError: If dynamic fetch fails
228
+ """
229
+ from logging import getLogger
230
+
231
+ from pylxpweb.constants import (
232
+ COUNTRY_MAP,
233
+ TIMEZONE_MAP,
234
+ get_continent_region_from_country,
235
+ )
236
+
237
+ _LOGGER = getLogger(__name__)
238
+
239
+ # Required fields for POST
240
+ data: dict[str, Any] = {
241
+ "plantId": str(plant_details["plantId"]),
242
+ "name": plant_details["name"],
243
+ "createDate": plant_details["createDate"],
244
+ "daylightSavingTime": plant_details["daylightSavingTime"],
245
+ }
246
+
247
+ # Map timezone: "GMT -8" → "WEST8"
248
+ timezone_human = plant_details["timezone"]
249
+ if timezone_human not in TIMEZONE_MAP:
250
+ raise ValueError(f"Unknown timezone: {timezone_human}")
251
+ data["timezone"] = TIMEZONE_MAP[timezone_human]
252
+
253
+ # Map country: "United States of America" → "UNITED_STATES_OF_AMERICA"
254
+ country_human = plant_details["country"]
255
+ if country_human not in COUNTRY_MAP:
256
+ raise ValueError(f"Unknown country: {country_human}")
257
+ data["country"] = COUNTRY_MAP[country_human]
258
+
259
+ # Hybrid approach: Try static mapping first, fall back to dynamic fetch
260
+ try:
261
+ # Fast path: static mapping
262
+ continent_enum, region_enum = get_continent_region_from_country(country_human)
263
+ _LOGGER.debug(
264
+ "Used static mapping for country '%s': %s/%s",
265
+ country_human,
266
+ continent_enum,
267
+ region_enum,
268
+ )
269
+ except ValueError:
270
+ # Slow path: dynamic fetch from locale API
271
+ _LOGGER.info(
272
+ "Country '%s' not in static mapping, fetching from locale API",
273
+ country_human,
274
+ )
275
+ continent_enum, region_enum = await self._fetch_country_location_from_api(country_human)
276
+
277
+ data["continent"] = continent_enum
278
+ data["region"] = region_enum
279
+
280
+ # Include nominalPower if present and not blank
281
+ if plant_details.get("nominalPower"):
282
+ data["nominalPower"] = plant_details["nominalPower"]
283
+
284
+ # Apply any overrides
285
+ data.update(overrides)
286
+
287
+ _LOGGER.info(
288
+ "Prepared plant update data for plant %s: timezone=%s, country=%s, "
289
+ "continent=%s, region=%s, dst=%s",
290
+ plant_details["plantId"],
291
+ data["timezone"],
292
+ data["country"],
293
+ data["continent"],
294
+ data["region"],
295
+ data["daylightSavingTime"],
296
+ )
297
+
298
+ return data
299
+
300
+ async def update_plant_config(self, plant_id: int | str, **kwargs: Any) -> dict[str, Any]:
301
+ """Update plant/station configuration.
302
+
303
+ Uses API-only data with mapping tables to convert human-readable values
304
+ to the enum format required by the POST endpoint. No HTML parsing needed.
305
+
306
+ Args:
307
+ plant_id: The plant/station ID
308
+ **kwargs: Configuration parameters to update:
309
+ - name (str): Station name
310
+ - nominalPower (int): Solar PV power rating in Watts
311
+ - daylightSavingTime (bool): DST enabled
312
+
313
+ Returns:
314
+ Dict containing API response (success status and message)
315
+
316
+ Raises:
317
+ LuxpowerAPIError: If update fails or validation error occurs
318
+ ValueError: If unable to map timezone or country values
319
+
320
+ Example:
321
+ # Toggle DST
322
+ await client.plants.update_plant_config(
323
+ "12345",
324
+ daylightSavingTime=True
325
+ )
326
+
327
+ # Update power rating
328
+ await client.plants.update_plant_config(
329
+ "12345",
330
+ nominalPower=20000
331
+ )
332
+ """
333
+ from logging import getLogger
334
+
335
+ _LOGGER = getLogger(__name__)
336
+ await self.client._ensure_authenticated()
337
+
338
+ # Get current configuration from API (human-readable values)
339
+ _LOGGER.info("Fetching plant details for plant %s", plant_id)
340
+ plant_details = await self.get_plant_details(plant_id)
341
+
342
+ # Prepare POST data using hybrid approach (static + dynamic mapping)
343
+ data = await self._prepare_plant_update_data(plant_details, **kwargs)
344
+
345
+ _LOGGER.info(
346
+ "Updating plant %s configuration: %s",
347
+ plant_id,
348
+ dict(kwargs),
349
+ )
350
+
351
+ response = await self.client._request("POST", "/WManage/web/config/plant/edit", data=data)
352
+
353
+ _LOGGER.info("Plant %s configuration updated successfully", plant_id)
354
+ return response
355
+
356
+ async def set_daylight_saving_time(self, plant_id: int | str, enabled: bool) -> dict[str, Any]:
357
+ """Set Daylight Saving Time (DST) for a plant/station.
358
+
359
+ Convenience method for toggling DST without affecting other settings.
360
+
361
+ Args:
362
+ plant_id: The plant/station ID
363
+ enabled: True to enable DST, False to disable
364
+
365
+ Returns:
366
+ Dict containing API response
367
+
368
+ Raises:
369
+ LuxpowerAPIError: If update fails
370
+
371
+ Example:
372
+ # Enable DST
373
+ await client.plants.set_daylight_saving_time("12345", True)
374
+
375
+ # Disable DST
376
+ await client.plants.set_daylight_saving_time("12345", False)
377
+ """
378
+ from logging import getLogger
379
+
380
+ _LOGGER = getLogger(__name__)
381
+ _LOGGER.info(
382
+ "Setting Daylight Saving Time to %s for plant %s",
383
+ "enabled" if enabled else "disabled",
384
+ plant_id,
385
+ )
386
+ return await self.update_plant_config(plant_id, daylightSavingTime=enabled)
387
+
388
+ async def get_plant_overview(self, search_text: str = "") -> dict[str, Any]:
389
+ """Get plant overview with real-time metrics.
390
+
391
+ This endpoint provides plant-level aggregated data including:
392
+ - Real-time power metrics (PV, charge, discharge, consumption)
393
+ - Energy totals (today, total)
394
+ - Inverter details nested within plant data
395
+
396
+ Args:
397
+ search_text: Optional search filter for plant name/address
398
+
399
+ Returns:
400
+ Dict containing:
401
+ - total: Total number of plants matching filter
402
+ - rows: List of plant objects with real-time metrics
403
+
404
+ Example:
405
+ overview = await client.plants.get_plant_overview()
406
+ for plant in overview["rows"]:
407
+ print(f"{plant['name']}: {plant['ppv']}W PV")
408
+ """
409
+ data = {"searchText": search_text}
410
+
411
+ response = await self.client._request(
412
+ "POST",
413
+ "/WManage/api/plantOverview/list/viewer",
414
+ data=data,
415
+ )
416
+
417
+ return dict(response)
418
+
419
+ async def get_inverter_overview(
420
+ self,
421
+ page: int = 1,
422
+ rows: int = 30,
423
+ plant_id: int = -1,
424
+ search_text: str = "",
425
+ status_filter: str = "all",
426
+ ) -> dict[str, Any]:
427
+ """Get paginated list of all inverters with real-time metrics.
428
+
429
+ This endpoint provides per-inverter data across all plants or filtered
430
+ by specific plant. Unlike get_plant_overview which aggregates at plant
431
+ level, this shows individual inverter metrics.
432
+
433
+ Args:
434
+ page: Page number (1-indexed)
435
+ rows: Number of rows per page (default 30)
436
+ plant_id: Plant ID (-1 for all plants, or specific plant ID)
437
+ search_text: Search filter for serial number or device name
438
+ status_filter: Status filter ("all", "normal", "fault", "offline")
439
+
440
+ Returns:
441
+ Dict containing:
442
+ - success: Boolean indicating success
443
+ - total: Total number of inverters matching filter
444
+ - rows: List of inverter objects with metrics
445
+
446
+ Example:
447
+ # All inverters across all plants
448
+ overview = await client.plants.get_inverter_overview()
449
+
450
+ # Inverters for specific plant
451
+ overview = await client.plants.get_inverter_overview(plant_id=19147)
452
+
453
+ # Only faulted inverters
454
+ overview = await client.plants.get_inverter_overview(status_filter="fault")
455
+ """
456
+ data = {
457
+ "page": page,
458
+ "rows": rows,
459
+ "plantId": plant_id,
460
+ "searchText": search_text,
461
+ "statusText": status_filter,
462
+ }
463
+
464
+ response = await self.client._request(
465
+ "POST",
466
+ "/WManage/api/inverterOverview/list",
467
+ data=data,
468
+ )
469
+
470
+ return dict(response)
pylxpweb/exceptions.py ADDED
@@ -0,0 +1,23 @@
1
+ """Exceptions for Luxpower/EG4 API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class LuxpowerError(Exception):
7
+ """Base exception for all Luxpower errors."""
8
+
9
+
10
+ class LuxpowerAuthError(LuxpowerError):
11
+ """Raised when authentication fails."""
12
+
13
+
14
+ class LuxpowerConnectionError(LuxpowerError):
15
+ """Raised when connection to the API fails."""
16
+
17
+
18
+ class LuxpowerAPIError(LuxpowerError):
19
+ """Raised when the API returns an error response."""
20
+
21
+
22
+ class LuxpowerDeviceError(LuxpowerError):
23
+ """Raised when there's an issue with device operations."""