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.
- pylxpweb/__init__.py +47 -2
- pylxpweb/api_namespace.py +241 -0
- pylxpweb/cli/__init__.py +3 -0
- pylxpweb/cli/collect_device_data.py +874 -0
- pylxpweb/client.py +387 -26
- pylxpweb/constants/__init__.py +481 -0
- pylxpweb/constants/api.py +48 -0
- pylxpweb/constants/devices.py +98 -0
- pylxpweb/constants/locations.py +227 -0
- pylxpweb/{constants.py → constants/registers.py} +72 -238
- pylxpweb/constants/scaling.py +479 -0
- pylxpweb/devices/__init__.py +32 -0
- pylxpweb/devices/_firmware_update_mixin.py +504 -0
- pylxpweb/devices/_mid_runtime_properties.py +545 -0
- pylxpweb/devices/base.py +122 -0
- pylxpweb/devices/battery.py +589 -0
- pylxpweb/devices/battery_bank.py +331 -0
- pylxpweb/devices/inverters/__init__.py +32 -0
- pylxpweb/devices/inverters/_features.py +378 -0
- pylxpweb/devices/inverters/_runtime_properties.py +596 -0
- pylxpweb/devices/inverters/base.py +2124 -0
- pylxpweb/devices/inverters/generic.py +192 -0
- pylxpweb/devices/inverters/hybrid.py +274 -0
- pylxpweb/devices/mid_device.py +183 -0
- pylxpweb/devices/models.py +126 -0
- pylxpweb/devices/parallel_group.py +351 -0
- pylxpweb/devices/station.py +908 -0
- pylxpweb/endpoints/control.py +980 -2
- pylxpweb/endpoints/devices.py +249 -16
- pylxpweb/endpoints/firmware.py +43 -10
- pylxpweb/endpoints/plants.py +15 -19
- pylxpweb/exceptions.py +4 -0
- pylxpweb/models.py +629 -40
- pylxpweb/transports/__init__.py +78 -0
- pylxpweb/transports/capabilities.py +101 -0
- pylxpweb/transports/data.py +495 -0
- pylxpweb/transports/exceptions.py +59 -0
- pylxpweb/transports/factory.py +119 -0
- pylxpweb/transports/http.py +329 -0
- pylxpweb/transports/modbus.py +557 -0
- pylxpweb/transports/protocol.py +217 -0
- {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.0.dist-info}/METADATA +130 -85
- pylxpweb-0.5.0.dist-info/RECORD +52 -0
- {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.0.dist-info}/WHEEL +1 -1
- pylxpweb-0.5.0.dist-info/entry_points.txt +3 -0
- 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))
|