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,908 @@
1
+ """Station (Plant) class for solar installations.
2
+
3
+ This module provides the Station class that represents a complete solar
4
+ installation with inverters, batteries, and optional MID devices.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ import zoneinfo
12
+ from dataclasses import dataclass
13
+ from datetime import UTC, datetime, timedelta, timezone
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from pylxpweb.constants import DEVICE_TYPE_GRIDBOSS, parse_hhmm_timezone
17
+ from pylxpweb.exceptions import LuxpowerAPIError, LuxpowerConnectionError, LuxpowerDeviceError
18
+ from pylxpweb.models import (
19
+ InverterOverviewResponse,
20
+ ParallelGroupDetailsResponse,
21
+ ParallelGroupDeviceItem,
22
+ )
23
+
24
+ from .base import BaseDevice
25
+ from .models import DeviceInfo, Entity
26
+
27
+ _LOGGER = logging.getLogger(__name__)
28
+
29
+ if TYPE_CHECKING:
30
+ from pylxpweb import LuxpowerClient
31
+
32
+ from .battery import Battery
33
+ from .inverters.base import BaseInverter
34
+ from .parallel_group import ParallelGroup
35
+
36
+
37
+ @dataclass
38
+ class Location:
39
+ """Geographic location information.
40
+
41
+ Attributes:
42
+ address: Street address
43
+ country: Country name or code
44
+
45
+ Note:
46
+ Latitude and longitude are not provided by the API.
47
+ """
48
+
49
+ address: str
50
+ country: str
51
+
52
+
53
+ class Station(BaseDevice):
54
+ """Represents a complete solar installation.
55
+
56
+ A single user account can have multiple stations (e.g., home, cabin, rental).
57
+ Each station is independent with its own device hierarchy.
58
+
59
+ The station manages:
60
+ - Parallel groups (if multi-inverter parallel operation)
61
+ - Standalone inverters (not in parallel groups)
62
+ - Weather data (optional)
63
+ - Aggregate statistics across all devices
64
+
65
+ Example:
66
+ ```python
67
+ # Load a station
68
+ station = await Station.load(client, plant_id=12345)
69
+
70
+ # Access devices
71
+ print(f"Total inverters: {len(station.all_inverters)}")
72
+ print(f"Total batteries: {len(station.all_batteries)}")
73
+
74
+ # Refresh all data
75
+ await station.refresh_all_data()
76
+
77
+ # Get production stats
78
+ stats = await station.get_total_production()
79
+ print(f"Today: {stats.today_kwh} kWh")
80
+ ```
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ client: LuxpowerClient,
86
+ plant_id: int,
87
+ name: str,
88
+ location: Location,
89
+ timezone: str,
90
+ created_date: datetime,
91
+ current_timezone_with_minute: int | None = None,
92
+ daylight_saving_time: bool = False,
93
+ ) -> None:
94
+ """Initialize station.
95
+
96
+ Args:
97
+ client: LuxpowerClient instance for API access
98
+ plant_id: Unique plant/station identifier
99
+ name: Human-readable station name
100
+ location: Geographic location information
101
+ timezone: Timezone string (e.g., "GMT -8")
102
+ created_date: Station creation timestamp
103
+ current_timezone_with_minute: Timezone offset in HHMM format
104
+ (e.g., -700 for PDT = -7:00)
105
+ daylight_saving_time: DST flag from API (may be incorrect)
106
+ """
107
+ # BaseDevice expects serial_number, but stations use plant_id
108
+ # We'll use str(plant_id) as the "serial number" for consistency
109
+ super().__init__(client, str(plant_id), "Solar Station")
110
+
111
+ self.id = plant_id
112
+ self.name = name
113
+ self.location = location
114
+ self.timezone = timezone # "GMT -8" (base timezone)
115
+ self.created_date = created_date
116
+
117
+ # Timezone precision fields
118
+ self.current_timezone_with_minute = current_timezone_with_minute # -700 = GMT-7:00 (PDT)
119
+ self.daylight_saving_time = daylight_saving_time # API's DST flag (may be wrong)
120
+
121
+ # Computed DST status (based on offset analysis)
122
+ self._actual_dst_active: bool | None = None
123
+
124
+ # Device collections (loaded by _load_devices)
125
+ self.parallel_groups: list[ParallelGroup] = []
126
+ self.standalone_inverters: list[BaseInverter] = []
127
+ self.weather: dict[str, Any] | None = None # Weather data (optional)
128
+
129
+ def detect_dst_status(self) -> bool | None:
130
+ """Detect if DST should be currently active based on system time and timezone.
131
+
132
+ This method uses Python's zoneinfo to determine if DST should be active
133
+ at the current date/time for the station's timezone. This is necessary because
134
+ the API's currentTimezoneWithMinute is not independent - it's calculated from
135
+ the base timezone + DST flag, creating circular logic.
136
+
137
+ IMPORTANT: This method requires an IANA timezone to be configured on the
138
+ LuxpowerClient (via the iana_timezone parameter). The API does not provide
139
+ sufficient location data to reliably determine the IANA timezone automatically.
140
+
141
+ Returns:
142
+ True if DST should be active, False if not, None if cannot determine
143
+ (no IANA timezone configured or invalid timezone).
144
+
145
+ Example:
146
+ # Client configured with IANA timezone
147
+ client = LuxpowerClient(username, password, iana_timezone="America/Los_Angeles")
148
+ station = await Station.load(client, plant_id)
149
+ dst_active = station.detect_dst_status() # Returns True/False
150
+
151
+ # Client without IANA timezone
152
+ client = LuxpowerClient(username, password)
153
+ station = await Station.load(client, plant_id)
154
+ dst_active = station.detect_dst_status() # Returns None (disabled)
155
+ """
156
+ try:
157
+ # Check if IANA timezone is configured
158
+ iana_timezone = getattr(self._client, "iana_timezone", None)
159
+ if not iana_timezone:
160
+ _LOGGER.debug(
161
+ "Station %s: DST detection disabled (no IANA timezone configured)",
162
+ self.id,
163
+ )
164
+ return None
165
+
166
+ # Validate timezone string
167
+ try:
168
+ tz = zoneinfo.ZoneInfo(iana_timezone)
169
+ except zoneinfo.ZoneInfoNotFoundError:
170
+ _LOGGER.error(
171
+ "Station %s: Invalid IANA timezone '%s'",
172
+ self.id,
173
+ iana_timezone,
174
+ )
175
+ return None
176
+
177
+ # Check if DST is active using zoneinfo
178
+ now = datetime.now(tz)
179
+ dst_offset = now.dst()
180
+ dst_active = dst_offset is not None and dst_offset.total_seconds() > 0
181
+
182
+ _LOGGER.debug(
183
+ "Station %s: DST detected using %s: %s (offset: %s)",
184
+ self.id,
185
+ iana_timezone,
186
+ "ACTIVE" if dst_active else "INACTIVE",
187
+ now.strftime("%z"),
188
+ )
189
+
190
+ return dst_active
191
+
192
+ except (zoneinfo.ZoneInfoNotFoundError, ValueError, KeyError) as e:
193
+ _LOGGER.debug("Station %s: Error detecting DST status: %s", self.id, e)
194
+ return None
195
+
196
+ async def sync_dst_setting(self) -> bool:
197
+ """Convenience method to synchronize DST setting with API if mismatch detected.
198
+
199
+ This is a convenience method that implementing applications can call to
200
+ automatically correct the API's DST flag based on the configured IANA timezone.
201
+ It does NOT run automatically - the application must explicitly call it.
202
+
203
+ Use case examples:
204
+ - Home Assistant: Add a config option "Auto-correct DST" and call this method
205
+ when enabled
206
+ - CLI tools: Provide a --sync-dst flag to trigger this method
207
+ - Periodic tasks: Call during daily maintenance windows
208
+
209
+ This method:
210
+ 1. Detects actual DST status using configured IANA timezone
211
+ 2. Compares with API's daylightSavingTime flag
212
+ 3. Updates API if mismatch found (only if needed)
213
+
214
+ IMPORTANT: This method requires an IANA timezone to be configured on the
215
+ LuxpowerClient. If not configured, sync will be skipped.
216
+
217
+ Returns:
218
+ True if setting was synced (or already correct), False if sync failed
219
+ or if DST detection is disabled (no IANA timezone configured).
220
+
221
+ Example:
222
+ ```python
223
+ # In Home Assistant integration config flow
224
+ if user_config.get("auto_correct_dst"):
225
+ station = await Station.load(client, plant_id)
226
+ await station.sync_dst_setting()
227
+ ```
228
+ """
229
+ actual_dst = self.detect_dst_status()
230
+
231
+ if actual_dst is None:
232
+ _LOGGER.debug(
233
+ "Station %s: DST detection disabled or failed, skipping sync",
234
+ self.id,
235
+ )
236
+ return False
237
+
238
+ # Check if API setting matches detected status
239
+ if actual_dst == self.daylight_saving_time:
240
+ _LOGGER.debug("Station %s: DST setting already correct (%s)", self.id, actual_dst)
241
+ return True
242
+
243
+ # Mismatch detected - update API
244
+ _LOGGER.warning(
245
+ "Station %s: DST mismatch detected! API reports %s but offset indicates %s. "
246
+ "Updating API setting...",
247
+ self.id,
248
+ self.daylight_saving_time,
249
+ actual_dst,
250
+ )
251
+
252
+ try:
253
+ success = await self.set_daylight_saving_time(actual_dst)
254
+ if success:
255
+ self.daylight_saving_time = actual_dst
256
+ _LOGGER.info(
257
+ "Station %s: Successfully updated DST setting to %s", self.id, actual_dst
258
+ )
259
+ else:
260
+ _LOGGER.error("Station %s: Failed to update DST setting", self.id)
261
+ return success
262
+
263
+ except (
264
+ LuxpowerAPIError,
265
+ LuxpowerConnectionError,
266
+ zoneinfo.ZoneInfoNotFoundError,
267
+ ValueError,
268
+ KeyError,
269
+ ) as e:
270
+ _LOGGER.error("Station %s: Error syncing DST setting: %s", self.id, e)
271
+ return False
272
+
273
+ @property
274
+ def current_date(self) -> str | None:
275
+ """Get current date in station's timezone as YYYY-MM-DD string.
276
+
277
+ This method uses currentTimezoneWithMinute (most accurate) as the primary
278
+ source, falling back to parsing the timezone string if unavailable.
279
+
280
+ Returns:
281
+ Date string in YYYY-MM-DD format, or None if timezone cannot be determined.
282
+
283
+ Example:
284
+ currentTimezoneWithMinute: -420 (7 hours behind UTC)
285
+ Current UTC: 2025-11-21 08:00
286
+ Result: "2025-11-21" (01:00 PST, same date)
287
+
288
+ currentTimezoneWithMinute: -420
289
+ Current UTC: 2025-11-22 06:30
290
+ Result: "2025-11-21" (23:30 PST, previous date)
291
+ """
292
+ try:
293
+ # Primary: Use currentTimezoneWithMinute (most accurate, includes DST)
294
+ # Note: This field is in HHMM format (e.g., -800 = -8:00), not literal minutes
295
+ if self.current_timezone_with_minute is not None:
296
+ # Parse HHMM format: -800 = -8 hours, 00 minutes
297
+ value = self.current_timezone_with_minute
298
+ hours, minutes = parse_hhmm_timezone(value)
299
+ total_minutes = hours * 60 + minutes
300
+
301
+ tz = timezone(timedelta(minutes=total_minutes))
302
+ result = datetime.now(tz).strftime("%Y-%m-%d")
303
+ _LOGGER.debug(
304
+ "Station %s: Date in timezone (offset %+d HHMM = %+d min): %s",
305
+ self.id,
306
+ self.current_timezone_with_minute,
307
+ total_minutes,
308
+ result,
309
+ )
310
+ return result
311
+
312
+ # Fallback: Parse base timezone string
313
+ if self.timezone and "GMT" in self.timezone:
314
+ offset_str = self.timezone.replace("GMT", "").strip()
315
+ if offset_str:
316
+ offset_hours = int(offset_str)
317
+ tz = timezone(timedelta(hours=offset_hours))
318
+ result = datetime.now(tz).strftime("%Y-%m-%d")
319
+ _LOGGER.debug(
320
+ "Station %s: Date using base timezone %s: %s",
321
+ self.id,
322
+ self.timezone,
323
+ result,
324
+ )
325
+ return result
326
+
327
+ # Last resort: UTC
328
+ _LOGGER.debug("Station %s: No timezone info available, using UTC", self.id)
329
+ return datetime.now(UTC).strftime("%Y-%m-%d")
330
+
331
+ except (zoneinfo.ZoneInfoNotFoundError, ValueError, KeyError) as e:
332
+ _LOGGER.debug("Error getting current date for station %s: %s", self.id, e)
333
+ return None
334
+
335
+ @property
336
+ def all_inverters(self) -> list[BaseInverter]:
337
+ """Get all inverters (parallel + standalone).
338
+
339
+ Returns:
340
+ List of all inverter objects in this station.
341
+ """
342
+ inverters: list[BaseInverter] = []
343
+ # Add inverters from parallel groups
344
+ for group in self.parallel_groups:
345
+ inverters.extend(group.inverters)
346
+ # Add standalone inverters
347
+ inverters.extend(self.standalone_inverters)
348
+ return inverters
349
+
350
+ @property
351
+ def all_batteries(self) -> list[Battery]:
352
+ """Get all batteries from all inverters.
353
+
354
+ Returns:
355
+ List of all battery objects across all inverters.
356
+ """
357
+
358
+ batteries: list[Battery] = []
359
+ for inverter in self.all_inverters:
360
+ if inverter._battery_bank and inverter._battery_bank.batteries:
361
+ batteries.extend(inverter._battery_bank.batteries)
362
+ return batteries
363
+
364
+ async def refresh(self) -> None:
365
+ """Refresh station metadata.
366
+
367
+ Note: This refreshes station-level data only.
368
+ Use refresh_all_data() to refresh all devices.
369
+ """
370
+ # Station-level data doesn't change often, just update timestamp
371
+ self._last_refresh = datetime.now()
372
+
373
+ async def refresh_all_data(self) -> None:
374
+ """Refresh runtime data for all devices concurrently.
375
+
376
+ This method:
377
+ 1. Checks if cache should be invalidated (hour boundaries)
378
+ 2. Refreshes all inverters (runtime and energy data)
379
+ 3. Refreshes all MID devices
380
+ 4. Does NOT reload device hierarchy (use load() for that)
381
+
382
+ Cache Invalidation:
383
+ Cache is automatically invalidated on the first request after any
384
+ hour boundary (handled by LuxpowerClient._request method). This
385
+ ensures fresh data at midnight for daily energy resets.
386
+ """
387
+
388
+ tasks = []
389
+
390
+ # Refresh all inverters (all inverters have refresh method)
391
+ for inverter in self.all_inverters:
392
+ tasks.append(inverter.refresh())
393
+
394
+ # Refresh MID devices (check for None, mid_device always has refresh method)
395
+ for group in self.parallel_groups:
396
+ if group.mid_device:
397
+ tasks.append(group.mid_device.refresh())
398
+
399
+ # Execute concurrently, ignore exceptions (partial failure OK)
400
+ if tasks:
401
+ await asyncio.gather(*tasks, return_exceptions=True)
402
+
403
+ self._last_refresh = datetime.now()
404
+
405
+ async def _warm_parameter_cache(self) -> None:
406
+ """Pre-fetch parameters for all inverters to eliminate first-access latency.
407
+
408
+ This optimization fetches parameters concurrently for all inverters during
409
+ initial station load, eliminating the ~300ms latency on first property access.
410
+
411
+ Called automatically by Station.load() and Station.load_all().
412
+
413
+ Benefits:
414
+ - First access to properties like `ac_charge_power_limit` is instant (<1ms)
415
+ - Reduces perceived latency in Home Assistant on integration startup
416
+ - All parameter properties return immediately (already cached)
417
+
418
+ Trade-offs:
419
+ - Adds 3 API calls per inverter on startup (parameter ranges 0-127, 127-254, 240-367)
420
+ - Increases initial load time by ~300ms (concurrent, not per-inverter)
421
+ - May fetch data that's never accessed
422
+ """
423
+ tasks = []
424
+
425
+ # Refresh parameters for all inverters concurrently (all inverters have refresh method)
426
+ for inverter in self.all_inverters:
427
+ # include_parameters=True triggers parameter fetch
428
+ tasks.append(inverter.refresh(include_parameters=True))
429
+
430
+ # Execute concurrently, ignore exceptions (partial failure OK)
431
+ if tasks:
432
+ await asyncio.gather(*tasks, return_exceptions=True)
433
+
434
+ async def _warm_parallel_group_energy_cache(self) -> None:
435
+ """Pre-fetch energy data for all parallel groups to eliminate first-access latency.
436
+
437
+ This optimization fetches energy data concurrently for all parallel groups during
438
+ initial station load, ensuring energy sensors show data immediately in Home Assistant.
439
+
440
+ Called automatically by Station.load() and Station.load_all().
441
+
442
+ Benefits:
443
+ - Parallel group energy properties return immediately with actual values
444
+ - Eliminates 0.00 kWh display on integration startup in Home Assistant
445
+ - All energy properties show real data from first access
446
+
447
+ Trade-offs:
448
+ - Adds 1 API call per parallel group on startup
449
+ - Minimal increase in initial load time (~100ms, concurrent)
450
+ """
451
+ tasks = []
452
+
453
+ # Fetch energy data for all parallel groups concurrently
454
+ for group in self.parallel_groups:
455
+ if group.inverters:
456
+ first_serial = group.inverters[0].serial_number
457
+ tasks.append(group._fetch_energy_data(first_serial))
458
+
459
+ # Execute concurrently, ignore exceptions (partial failure OK)
460
+ if tasks:
461
+ await asyncio.gather(*tasks, return_exceptions=True)
462
+
463
+ async def get_total_production(self) -> dict[str, float]:
464
+ """Calculate total energy production across all inverters.
465
+
466
+ Returns:
467
+ Dictionary with 'today_kwh' and 'lifetime_kwh' totals.
468
+ """
469
+ total_today = 0.0
470
+ total_lifetime = 0.0
471
+
472
+ for inverter in self.all_inverters:
473
+ # Refresh if needed (all inverters have these attributes)
474
+ if inverter.needs_refresh:
475
+ await inverter.refresh()
476
+
477
+ # Sum energy data (all inverters have _energy attribute)
478
+ if inverter._energy:
479
+ total_today += getattr(inverter._energy, "eToday", 0.0)
480
+ total_lifetime += getattr(inverter._energy, "eTotal", 0.0)
481
+
482
+ return {
483
+ "today_kwh": total_today,
484
+ "lifetime_kwh": total_lifetime,
485
+ }
486
+
487
+ def to_device_info(self) -> DeviceInfo:
488
+ """Convert to device info model.
489
+
490
+ Returns:
491
+ DeviceInfo with station metadata.
492
+ """
493
+ return DeviceInfo(
494
+ identifiers={("pylxpweb", f"station_{self.id}")},
495
+ name=f"Station: {self.name}",
496
+ manufacturer="EG4/Luxpower",
497
+ model="Solar Station",
498
+ )
499
+
500
+ def to_entities(self) -> list[Entity]:
501
+ """Generate entities for station.
502
+
503
+ Returns:
504
+ List of station-level entities (aggregated metrics).
505
+ """
506
+ entities = []
507
+
508
+ # Total production today
509
+ entities.append(
510
+ Entity(
511
+ unique_id=f"station_{self.id}_total_production_today",
512
+ name=f"{self.name} Total Production Today",
513
+ device_class="energy",
514
+ state_class="total_increasing",
515
+ unit_of_measurement="kWh",
516
+ value=0.0, # Will be updated by data coordinator
517
+ )
518
+ )
519
+
520
+ # Total power
521
+ entities.append(
522
+ Entity(
523
+ unique_id=f"station_{self.id}_total_power",
524
+ name=f"{self.name} Total Power",
525
+ device_class="power",
526
+ state_class="measurement",
527
+ unit_of_measurement="W",
528
+ value=0.0, # Will be updated by data coordinator
529
+ )
530
+ )
531
+
532
+ return entities
533
+
534
+ @classmethod
535
+ async def load(cls, client: LuxpowerClient, plant_id: int) -> Station:
536
+ """Load a station from the API with all devices.
537
+
538
+ Args:
539
+ client: LuxpowerClient instance
540
+ plant_id: Plant/station ID to load
541
+
542
+ Returns:
543
+ Station instance with device hierarchy loaded
544
+
545
+ Example:
546
+ ```python
547
+ station = await Station.load(client, plant_id=12345)
548
+ print(f"Loaded {len(station.all_inverters)} inverters")
549
+ ```
550
+ """
551
+ from datetime import datetime
552
+
553
+ # Get plant details from API
554
+ plant_data = await client.api.plants.get_plant_details(plant_id)
555
+
556
+ # Create Location from plant data
557
+ location = Location(
558
+ address=plant_data.get("address", ""),
559
+ country=plant_data.get("country", ""),
560
+ )
561
+
562
+ # Parse creation date
563
+ created_date_str = plant_data.get("createDate", "")
564
+ try:
565
+ created_date = datetime.fromisoformat(created_date_str.replace("Z", "+00:00"))
566
+ except (ValueError, AttributeError):
567
+ created_date = datetime.now()
568
+
569
+ # Create station instance with timezone fields
570
+ station = cls(
571
+ client=client,
572
+ plant_id=plant_id,
573
+ name=plant_data.get("name", f"Station {plant_id}"),
574
+ location=location,
575
+ timezone=plant_data.get("timezone", "UTC"),
576
+ created_date=created_date,
577
+ current_timezone_with_minute=plant_data.get("currentTimezoneWithMinute"),
578
+ daylight_saving_time=plant_data.get("daylightSavingTime", False),
579
+ )
580
+
581
+ # Load device hierarchy
582
+ await station._load_devices()
583
+
584
+ # Warm caches for better initial performance (optimization)
585
+ # This pre-fetches data to eliminate first-access latency
586
+ await station._warm_parameter_cache()
587
+ await station._warm_parallel_group_energy_cache()
588
+
589
+ return station
590
+
591
+ @classmethod
592
+ async def load_all(cls, client: LuxpowerClient) -> list[Station]:
593
+ """Load all stations accessible by the current user.
594
+
595
+ Args:
596
+ client: LuxpowerClient instance
597
+
598
+ Returns:
599
+ List of Station instances with device hierarchies loaded
600
+
601
+ Example:
602
+ ```python
603
+ stations = await Station.load_all(client)
604
+ for station in stations:
605
+ print(f"{station.name}: {len(station.all_inverters)} inverters")
606
+ ```
607
+ """
608
+ # Get all plants from API
609
+ plants_response = await client.api.plants.get_plants()
610
+
611
+ # Load each station concurrently
612
+ tasks = [cls.load(client, plant.plantId) for plant in plants_response.rows]
613
+ return await asyncio.gather(*tasks)
614
+
615
+ async def _load_devices(self) -> None:
616
+ """Load device hierarchy from API.
617
+
618
+ This method orchestrates device loading by:
619
+ 1. Getting device list from API
620
+ 2. Finding GridBOSS to query parallel group configuration
621
+ 3. If GridBOSS found but no parallel groups, trigger auto-sync
622
+ 4. Creating ParallelGroup objects
623
+ 5. Assigning inverters and MID devices to groups or standalone list
624
+ """
625
+ try:
626
+ # Get device list and parallel group configuration
627
+ devices_response = await self._get_device_list()
628
+ gridboss_serial = self._find_gridboss(devices_response)
629
+ group_data = await self._get_parallel_groups(gridboss_serial)
630
+
631
+ # If GridBOSS detected but no parallel group data, trigger auto-sync
632
+ if gridboss_serial and (not group_data or not group_data.devices):
633
+ _LOGGER.info(
634
+ "GridBOSS %s detected but no parallel groups found, triggering auto-sync",
635
+ gridboss_serial,
636
+ )
637
+ sync_success = await self._client.api.devices.sync_parallel_groups(self.id)
638
+ if sync_success:
639
+ _LOGGER.info("Parallel group sync successful, re-fetching group data")
640
+ # Re-fetch parallel group data after sync
641
+ group_data = await self._get_parallel_groups(gridboss_serial)
642
+ else:
643
+ _LOGGER.warning(
644
+ "Parallel group sync failed for station %s - GridBOSS may not appear",
645
+ self.id,
646
+ )
647
+
648
+ # Create parallel groups and lookup dictionary
649
+ groups_lookup = self._create_parallel_groups(group_data, devices_response)
650
+
651
+ # Assign devices to groups or standalone
652
+ self._assign_devices(devices_response, groups_lookup)
653
+
654
+ except (LuxpowerAPIError, LuxpowerConnectionError, LuxpowerDeviceError) as e:
655
+ # If device loading fails, log and continue
656
+ # Station can still function with empty device lists
657
+ _LOGGER.warning("Failed to load devices for station %s: %s", self.id, e, exc_info=True)
658
+
659
+ async def _get_device_list(self) -> InverterOverviewResponse:
660
+ """Get device list from API.
661
+
662
+ Returns:
663
+ Device list response from API.
664
+ """
665
+ return await self._client.api.devices.get_devices(self.id)
666
+
667
+ def _find_gridboss(self, devices_response: InverterOverviewResponse) -> str | None:
668
+ """Find GridBOSS device in device list.
669
+
670
+ Args:
671
+ devices_response: Device list response from API.
672
+
673
+ Returns:
674
+ GridBOSS serial number if found, None otherwise.
675
+ """
676
+ if not devices_response.rows:
677
+ return None
678
+
679
+ for device in devices_response.rows:
680
+ if device.deviceType == DEVICE_TYPE_GRIDBOSS:
681
+ _LOGGER.debug("Found GridBOSS device: %s", device.serialNum)
682
+ return device.serialNum
683
+
684
+ return None
685
+
686
+ async def _get_parallel_groups(
687
+ self, gridboss_serial: str | None
688
+ ) -> ParallelGroupDetailsResponse | None:
689
+ """Get parallel group details if GridBOSS exists.
690
+
691
+ Args:
692
+ gridboss_serial: GridBOSS serial number or None.
693
+
694
+ Returns:
695
+ Parallel group details or None if not available.
696
+ """
697
+ if not gridboss_serial:
698
+ return None
699
+
700
+ try:
701
+ return await self._client.api.devices.get_parallel_group_details(gridboss_serial)
702
+ except (LuxpowerAPIError, LuxpowerConnectionError, LuxpowerDeviceError) as e:
703
+ _LOGGER.debug("Could not load parallel group details: %s", str(e))
704
+ return None
705
+
706
+ def _create_parallel_groups(
707
+ self,
708
+ group_data: ParallelGroupDetailsResponse | None,
709
+ devices_response: InverterOverviewResponse,
710
+ ) -> dict[str, ParallelGroup]:
711
+ """Create ParallelGroup objects from API data.
712
+
713
+ Args:
714
+ group_data: Parallel group details from API.
715
+ devices_response: Device list for looking up group names.
716
+
717
+ Returns:
718
+ Dictionary mapping group names to ParallelGroup objects.
719
+ """
720
+ from .parallel_group import ParallelGroup
721
+
722
+ if not group_data or not group_data.devices:
723
+ return {}
724
+
725
+ # Create device lookup for O(1) access
726
+ device_lookup = {d.serialNum: d for d in devices_response.rows}
727
+
728
+ # Group devices by parallelGroup field
729
+ groups_by_name: dict[str, list[ParallelGroupDeviceItem]] = {}
730
+ for pg_device in group_data.devices:
731
+ device_info = device_lookup.get(pg_device.serialNum)
732
+ if device_info and device_info.parallelGroup:
733
+ group_name = device_info.parallelGroup
734
+ if group_name not in groups_by_name:
735
+ groups_by_name[group_name] = []
736
+ groups_by_name[group_name].append(pg_device)
737
+
738
+ # Create ParallelGroup objects
739
+ groups_lookup = {}
740
+ for group_name, devices in groups_by_name.items():
741
+ first_serial = devices[0].serialNum if devices else ""
742
+ group = ParallelGroup(
743
+ client=self._client,
744
+ station=self,
745
+ name=group_name,
746
+ first_device_serial=first_serial,
747
+ )
748
+ self.parallel_groups.append(group)
749
+ groups_lookup[group_name] = group
750
+ _LOGGER.debug("Created parallel group '%s' with %d devices", group_name, len(devices))
751
+
752
+ return groups_lookup
753
+
754
+ def _assign_devices(
755
+ self, devices_response: InverterOverviewResponse, groups_lookup: dict[str, ParallelGroup]
756
+ ) -> None:
757
+ """Assign devices to parallel groups or standalone list.
758
+
759
+ Args:
760
+ devices_response: Device list from API.
761
+ groups_lookup: Dictionary mapping group names to ParallelGroup objects.
762
+ """
763
+
764
+ if not devices_response.rows:
765
+ return
766
+
767
+ for device_data in devices_response.rows:
768
+ if not device_data.serialNum:
769
+ continue
770
+
771
+ serial_num = device_data.serialNum
772
+ device_type = device_data.deviceType
773
+ model_text = device_data.deviceTypeText
774
+ parallel_group_name = device_data.parallelGroup
775
+
776
+ # Handle GridBOSS/MID devices
777
+ if device_type == DEVICE_TYPE_GRIDBOSS:
778
+ self._assign_mid_device(serial_num, model_text, parallel_group_name, groups_lookup)
779
+ continue
780
+
781
+ # Handle inverters
782
+ self._assign_inverter(serial_num, model_text, parallel_group_name, groups_lookup)
783
+
784
+ def _assign_mid_device(
785
+ self,
786
+ serial_num: str,
787
+ model_text: str,
788
+ parallel_group_name: str | None,
789
+ groups_lookup: dict[str, ParallelGroup],
790
+ ) -> None:
791
+ """Assign MID device to parallel group.
792
+
793
+ Args:
794
+ serial_num: MID device serial number.
795
+ model_text: MID device model name.
796
+ parallel_group_name: Parallel group name or None.
797
+ groups_lookup: Dictionary mapping group names to ParallelGroup objects.
798
+ """
799
+ from .mid_device import MIDDevice
800
+
801
+ mid_device = MIDDevice(client=self._client, serial_number=serial_num, model=model_text)
802
+
803
+ if parallel_group_name:
804
+ found_group = groups_lookup.get(parallel_group_name)
805
+ if found_group:
806
+ found_group.mid_device = mid_device
807
+ _LOGGER.debug(
808
+ "Assigned MID device %s to parallel group '%s'",
809
+ serial_num,
810
+ parallel_group_name,
811
+ )
812
+ else:
813
+ _LOGGER.warning(
814
+ "Parallel group '%s' not found for MID device %s",
815
+ parallel_group_name,
816
+ serial_num,
817
+ )
818
+ else:
819
+ _LOGGER.warning("MID device %s has no parallel group assignment", serial_num)
820
+
821
+ def _assign_inverter(
822
+ self,
823
+ serial_num: str,
824
+ model_text: str,
825
+ parallel_group_name: str | None,
826
+ groups_lookup: dict[str, ParallelGroup],
827
+ ) -> None:
828
+ """Assign inverter to parallel group or standalone list.
829
+
830
+ Args:
831
+ serial_num: Inverter serial number.
832
+ model_text: Inverter model name.
833
+ parallel_group_name: Parallel group name or None.
834
+ groups_lookup: Dictionary mapping group names to ParallelGroup objects.
835
+ """
836
+ from .inverters.generic import GenericInverter
837
+
838
+ inverter = GenericInverter(client=self._client, serial_number=serial_num, model=model_text)
839
+
840
+ if parallel_group_name:
841
+ found_group = groups_lookup.get(parallel_group_name)
842
+ if found_group:
843
+ found_group.inverters.append(inverter)
844
+ _LOGGER.debug(
845
+ "Assigned inverter %s to parallel group '%s'",
846
+ serial_num,
847
+ parallel_group_name,
848
+ )
849
+ else:
850
+ # If parallel group not found, treat as standalone
851
+ self.standalone_inverters.append(inverter)
852
+ _LOGGER.debug(
853
+ "Parallel group '%s' not found for %s - treating as standalone",
854
+ parallel_group_name,
855
+ serial_num,
856
+ )
857
+ else:
858
+ # Standalone inverter
859
+ self.standalone_inverters.append(inverter)
860
+ _LOGGER.debug("Assigned inverter %s as standalone", serial_num)
861
+
862
+ # ============================================================================
863
+ # Station-Level Control Operations (Issue #15)
864
+ # ============================================================================
865
+
866
+ async def set_daylight_saving_time(self, enabled: bool) -> bool:
867
+ """Set daylight saving time adjustment for the station.
868
+
869
+ This is a station-level setting that affects all devices in the station.
870
+
871
+ After a successful write, the cached DST state is updated to reflect the new value.
872
+ This ensures that subsequent reads return the correct state without requiring
873
+ an additional API call.
874
+
875
+ Args:
876
+ enabled: True to enable DST, False to disable
877
+
878
+ Returns:
879
+ True if successful, False otherwise
880
+
881
+ Example:
882
+ >>> await station.set_daylight_saving_time(True)
883
+ True
884
+ >>> station.daylight_saving_time # Immediately reflects new value
885
+ True
886
+ """
887
+ result = await self._client.api.plants.set_daylight_saving_time(self.id, enabled)
888
+ success = bool(result.get("success", False))
889
+
890
+ # Update cached state on successful write to ensure consistency
891
+ if success:
892
+ self.daylight_saving_time = enabled
893
+
894
+ return success
895
+
896
+ async def get_daylight_saving_time_enabled(self) -> bool:
897
+ """Get current daylight saving time setting.
898
+
899
+ Returns:
900
+ True if DST is enabled, False otherwise
901
+
902
+ Example:
903
+ >>> is_dst = await station.get_daylight_saving_time_enabled()
904
+ >>> is_dst
905
+ False
906
+ """
907
+ plant_details = await self._client.api.plants.get_plant_details(self.id)
908
+ return bool(plant_details.get("daylightSavingTime", False))