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,2124 @@
1
+ """Base inverter class for all inverter types.
2
+
3
+ This module provides the BaseInverter abstract class that all model-specific
4
+ inverter implementations must inherit from.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ from abc import abstractmethod
12
+ from datetime import datetime, timedelta
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from pylxpweb.constants import MAX_REGISTERS_PER_READ, SOC_MAX_PERCENT, SOC_MIN_PERCENT
16
+ from pylxpweb.exceptions import LuxpowerAPIError, LuxpowerConnectionError, LuxpowerDeviceError
17
+ from pylxpweb.models import OperatingMode
18
+
19
+ from .._firmware_update_mixin import FirmwareUpdateMixin
20
+ from ..base import BaseDevice
21
+ from ..models import DeviceInfo, Entity
22
+ from ._features import (
23
+ GridType,
24
+ InverterFamily,
25
+ InverterFeatures,
26
+ InverterModelInfo,
27
+ )
28
+ from ._runtime_properties import InverterRuntimePropertiesMixin
29
+
30
+ _LOGGER = logging.getLogger(__name__)
31
+
32
+ if TYPE_CHECKING:
33
+ from pylxpweb import LuxpowerClient
34
+ from pylxpweb.models import EnergyInfo, InverterRuntime
35
+
36
+
37
+ class BaseInverter(FirmwareUpdateMixin, InverterRuntimePropertiesMixin, BaseDevice):
38
+ """Abstract base class for all inverter types.
39
+
40
+ All model-specific inverter classes (FlexBOSS, 18KPV, etc.) must inherit
41
+ from this class and implement its abstract methods.
42
+
43
+ Attributes:
44
+ runtime: Cached runtime data (power, voltage, current, temperature)
45
+ energy: Cached energy data (daily, monthly, lifetime production)
46
+ batteries: List of battery objects connected to this inverter
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ client: LuxpowerClient,
52
+ serial_number: str,
53
+ model: str,
54
+ ) -> None:
55
+ """Initialize inverter.
56
+
57
+ Args:
58
+ client: LuxpowerClient instance for API access
59
+ serial_number: Inverter serial number (10-digit)
60
+ model: Inverter model name (e.g., "FlexBOSS21", "18KPV")
61
+ """
62
+ super().__init__(client, serial_number, model)
63
+
64
+ # Runtime data (refreshed frequently) - PRIVATE: use properties for access
65
+ self._runtime: InverterRuntime | None = None
66
+
67
+ # Energy data (refreshed less frequently) - PRIVATE: use properties for access
68
+ self._energy: EnergyInfo | None = None
69
+
70
+ # Battery bank (aggregate data and individual batteries) - PRIVATE: use properties
71
+ self._battery_bank: Any | None = None # Will be BatteryBank object
72
+
73
+ # Parameters (configuration registers, refreshed hourly)
74
+ self.parameters: dict[str, Any] | None = None
75
+
76
+ # ===== Cache Management =====
77
+ # Parameters cache time tracking
78
+ self._parameters_cache_time: datetime | None = None
79
+ self._parameters_cache_ttl = timedelta(hours=1) # 1-hour TTL for parameters
80
+ self._parameters_cache_lock = asyncio.Lock()
81
+
82
+ # Runtime data cache
83
+ self._runtime_cache_time: datetime | None = None
84
+ self._runtime_cache_ttl = timedelta(seconds=30) # 30-second TTL for runtime
85
+ self._runtime_cache_lock = asyncio.Lock()
86
+
87
+ # Energy data cache
88
+ self._energy_cache_time: datetime | None = None
89
+ self._energy_cache_ttl = timedelta(minutes=5) # 5-minute TTL for energy
90
+ self._energy_cache_lock = asyncio.Lock()
91
+
92
+ # Battery data cache
93
+ self._battery_cache_time: datetime | None = None
94
+ self._battery_cache_ttl = timedelta(seconds=30) # 30-second TTL for battery
95
+ self._battery_cache_lock = asyncio.Lock()
96
+
97
+ # ===== Firmware Update Cache =====
98
+ # Initialize firmware update detection (from FirmwareUpdateMixin)
99
+ self._init_firmware_update_cache()
100
+
101
+ # ===== Feature Detection =====
102
+ # Detected inverter features and capabilities
103
+ self._features: InverterFeatures = InverterFeatures()
104
+ self._features_detected: bool = False
105
+
106
+ def _is_cache_expired(
107
+ self,
108
+ cache_time: datetime | None,
109
+ ttl: timedelta,
110
+ force: bool,
111
+ ) -> bool:
112
+ """Check if cache entry has expired.
113
+
114
+ Args:
115
+ cache_time: Timestamp of cached data
116
+ ttl: Time-to-live for this cache
117
+ force: If True, always return True (force refresh)
118
+
119
+ Returns:
120
+ True if cache is expired or missing
121
+ """
122
+ if force:
123
+ return True
124
+ if cache_time is None:
125
+ return True
126
+ return (datetime.now() - cache_time) > ttl
127
+
128
+ async def refresh(self, force: bool = False, include_parameters: bool = False) -> None:
129
+ """Refresh runtime, energy, battery, and optionally parameters from API.
130
+
131
+ This method fetches data concurrently for optimal performance.
132
+ Results are cached with different TTLs based on update frequency.
133
+
134
+ Args:
135
+ force: If True, bypass cache and force fresh data from API
136
+ include_parameters: If True, also refresh parameters (default: False)
137
+ """
138
+ # Prepare tasks to fetch only expired/missing data
139
+ tasks = []
140
+
141
+ # Runtime data (30s TTL)
142
+ if self._is_cache_expired(self._runtime_cache_time, self._runtime_cache_ttl, force):
143
+ tasks.append(self._fetch_runtime())
144
+
145
+ # Energy data (5min TTL)
146
+ if self._is_cache_expired(self._energy_cache_time, self._energy_cache_ttl, force):
147
+ tasks.append(self._fetch_energy())
148
+
149
+ # Battery data (30s TTL) - Lazy loading optimization
150
+ # Only fetch if we have batteries OR haven't checked yet (first fetch)
151
+ if self._is_cache_expired(self._battery_cache_time, self._battery_cache_ttl, force):
152
+ should_fetch_battery = (
153
+ self._battery_bank is None # Haven't checked yet
154
+ or (self._battery_bank and self._battery_bank.battery_count > 0) # Has batteries
155
+ )
156
+ if should_fetch_battery:
157
+ tasks.append(self._fetch_battery())
158
+
159
+ # Parameters (1hr TTL) - only fetch if explicitly requested
160
+ if include_parameters and self._is_cache_expired(
161
+ self._parameters_cache_time, self._parameters_cache_ttl, force
162
+ ):
163
+ tasks.append(self._fetch_parameters())
164
+
165
+ # Execute all needed fetches concurrently
166
+ if tasks:
167
+ await asyncio.gather(*tasks, return_exceptions=True)
168
+
169
+ self._last_refresh = datetime.now()
170
+
171
+ async def _fetch_runtime(self) -> None:
172
+ """Fetch runtime data with caching."""
173
+ async with self._runtime_cache_lock:
174
+ try:
175
+ runtime_data = await self._client.api.devices.get_inverter_runtime(
176
+ self.serial_number
177
+ )
178
+ self._runtime = runtime_data
179
+ self._runtime_cache_time = datetime.now()
180
+ except (LuxpowerAPIError, LuxpowerConnectionError, LuxpowerDeviceError) as err:
181
+ # Keep existing cached data on API/connection errors
182
+ _LOGGER.debug("Failed to fetch runtime data for %s: %s", self.serial_number, err)
183
+ # Preserve existing cached data
184
+
185
+ async def _fetch_energy(self) -> None:
186
+ """Fetch energy data with caching."""
187
+ async with self._energy_cache_lock:
188
+ try:
189
+ energy_data = await self._client.api.devices.get_inverter_energy(self.serial_number)
190
+ self._energy = energy_data
191
+ self._energy_cache_time = datetime.now()
192
+ except (LuxpowerAPIError, LuxpowerConnectionError, LuxpowerDeviceError) as err:
193
+ # Keep existing cached data on API/connection errors
194
+ _LOGGER.debug("Failed to fetch energy data for %s: %s", self.serial_number, err)
195
+
196
+ async def _fetch_battery(self) -> None:
197
+ """Fetch battery data with caching."""
198
+ async with self._battery_cache_lock:
199
+ try:
200
+ battery_data = await self._client.api.devices.get_battery_info(self.serial_number)
201
+
202
+ # Create/update battery bank with aggregate data
203
+ await self._update_battery_bank(battery_data)
204
+
205
+ # Update individual batteries
206
+ if battery_data.batteryArray:
207
+ await self._update_batteries(battery_data.batteryArray)
208
+
209
+ self._battery_cache_time = datetime.now()
210
+ except (LuxpowerAPIError, LuxpowerConnectionError, LuxpowerDeviceError) as err:
211
+ # Keep existing cached data on API/connection errors
212
+ _LOGGER.debug("Failed to fetch battery data for %s: %s", self.serial_number, err)
213
+
214
+ async def _fetch_parameters(self) -> None:
215
+ """Fetch all parameters with caching.
216
+
217
+ Fetches parameters from all 3 register ranges concurrently:
218
+ - Range 1: Registers 0-126 (base parameters)
219
+ - Range 2: Registers 127-253 (extended parameters 1)
220
+ - Range 3: Registers 240-366 (extended parameters 2)
221
+ """
222
+ async with self._parameters_cache_lock:
223
+ try:
224
+ # Fetch all 3 register ranges concurrently
225
+ range_tasks = [
226
+ self._client.api.control.read_parameters(
227
+ self.serial_number, 0, MAX_REGISTERS_PER_READ
228
+ ),
229
+ self._client.api.control.read_parameters(
230
+ self.serial_number, MAX_REGISTERS_PER_READ, MAX_REGISTERS_PER_READ
231
+ ),
232
+ self._client.api.control.read_parameters(
233
+ self.serial_number, 240, MAX_REGISTERS_PER_READ
234
+ ),
235
+ ]
236
+
237
+ responses = await asyncio.gather(*range_tasks, return_exceptions=True)
238
+
239
+ # Merge all parameter dictionaries
240
+ all_parameters: dict[str, Any] = {}
241
+ for response in responses:
242
+ if not isinstance(response, BaseException):
243
+ all_parameters.update(response.parameters)
244
+
245
+ # Only update if we got at least some parameters
246
+ if all_parameters:
247
+ self.parameters = all_parameters
248
+ self._parameters_cache_time = datetime.now()
249
+ except (LuxpowerAPIError, LuxpowerConnectionError, LuxpowerDeviceError) as err:
250
+ # Keep existing cached data on API/connection errors
251
+ _LOGGER.debug("Failed to fetch parameters for %s: %s", self.serial_number, err)
252
+
253
+ def to_device_info(self) -> DeviceInfo:
254
+ """Convert to device info model.
255
+
256
+ Returns:
257
+ DeviceInfo with inverter metadata.
258
+ """
259
+ return DeviceInfo(
260
+ identifiers={("pylxpweb", f"inverter_{self.serial_number}")},
261
+ name=f"{self.model} {self.serial_number}",
262
+ manufacturer="EG4/Luxpower",
263
+ model=self.model,
264
+ sw_version=getattr(self._runtime, "fwCode", None) if self._runtime else None,
265
+ )
266
+
267
+ @abstractmethod
268
+ def to_entities(self) -> list[Entity]:
269
+ """Generate entities for this inverter.
270
+
271
+ Each inverter model may have different available entities based on
272
+ hardware capabilities. Subclasses must implement this method.
273
+
274
+ Returns:
275
+ List of Entity objects for this inverter model.
276
+ """
277
+ ...
278
+
279
+ @property
280
+ def model(self) -> str:
281
+ """Get inverter model name.
282
+
283
+ Returns the human-readable model name from deviceTypeText provided
284
+ during initialization. This is set during Station.load() from the
285
+ inverterOverview/list API response.
286
+
287
+ Returns:
288
+ Inverter model name (e.g., "18KPV", "FlexBOSS21"), or "Unknown" if unavailable.
289
+ """
290
+ return self._model if self._model else "Unknown"
291
+
292
+ @property
293
+ def has_data(self) -> bool:
294
+ """Check if inverter has valid runtime data.
295
+
296
+ Returns:
297
+ True if runtime data is available, False otherwise.
298
+ """
299
+ return self._runtime is not None
300
+
301
+ @property
302
+ def power_output(self) -> float:
303
+ """Get current power output in watts.
304
+
305
+ Returns:
306
+ Current AC power output in watts, or 0.0 if no data.
307
+ """
308
+ if self._runtime is None:
309
+ return 0.0
310
+ return float(getattr(self._runtime, "pinv", 0))
311
+
312
+ @property
313
+ def total_energy_today(self) -> float:
314
+ """Get total energy produced today in kWh.
315
+
316
+ This is a daily value that resets at midnight (API server time).
317
+ Home Assistant's SensorStateClass.TOTAL_INCREASING handles resets.
318
+
319
+ Returns:
320
+ Energy produced today in kWh, or 0.0 if no data.
321
+ """
322
+ if self._energy is None:
323
+ return 0.0
324
+
325
+ from pylxpweb.constants import scale_energy_value
326
+
327
+ raw_value = getattr(self._energy, "todayYielding", 0)
328
+ return scale_energy_value("todayYielding", raw_value, to_kwh=True)
329
+
330
+ @property
331
+ def total_energy_lifetime(self) -> float:
332
+ """Get total energy produced lifetime in kWh.
333
+
334
+ Returns:
335
+ Total lifetime energy in kWh, or 0.0 if no data.
336
+ """
337
+ if self._energy is None:
338
+ return 0.0
339
+
340
+ from pylxpweb.constants import scale_energy_value
341
+
342
+ raw_value = getattr(self._energy, "totalYielding", 0)
343
+ return scale_energy_value("totalYielding", raw_value, to_kwh=True)
344
+
345
+ @property
346
+ def battery_soc(self) -> int | None:
347
+ """Get battery state of charge percentage.
348
+
349
+ Returns:
350
+ Battery SOC (0-100), or None if no data.
351
+ """
352
+ if self._runtime is None:
353
+ return None
354
+ return getattr(self._runtime, "soc", None)
355
+
356
+ @property
357
+ def battery_bank(self) -> Any | None:
358
+ """Get battery bank with aggregate data and individual batteries.
359
+
360
+ Returns:
361
+ BatteryBank object with batteries list, or None if no battery data.
362
+ """
363
+ return self._battery_bank
364
+
365
+ # ============================================================================
366
+ # Additional Energy Statistics Properties
367
+ # ============================================================================
368
+
369
+ @property
370
+ def energy_today_charging(self) -> float:
371
+ """Get battery charging energy today in kWh.
372
+
373
+ Returns:
374
+ Energy charged to battery today in kWh, or 0.0 if no data.
375
+ """
376
+ if self._energy is None:
377
+ return 0.0
378
+ from pylxpweb.constants import scale_energy_value
379
+
380
+ return scale_energy_value("todayCharging", self._energy.todayCharging, to_kwh=True)
381
+
382
+ @property
383
+ def energy_today_discharging(self) -> float:
384
+ """Get battery discharging energy today in kWh.
385
+
386
+ Returns:
387
+ Energy discharged from battery today in kWh, or 0.0 if no data.
388
+ """
389
+ if self._energy is None:
390
+ return 0.0
391
+ from pylxpweb.constants import scale_energy_value
392
+
393
+ return scale_energy_value("todayDischarging", self._energy.todayDischarging, to_kwh=True)
394
+
395
+ @property
396
+ def energy_today_import(self) -> float:
397
+ """Get grid import energy today in kWh.
398
+
399
+ Returns:
400
+ Energy imported from grid today in kWh, or 0.0 if no data.
401
+ """
402
+ if self._energy is None:
403
+ return 0.0
404
+ from pylxpweb.constants import scale_energy_value
405
+
406
+ return scale_energy_value("todayImport", self._energy.todayImport, to_kwh=True)
407
+
408
+ @property
409
+ def energy_today_export(self) -> float:
410
+ """Get grid export energy today in kWh.
411
+
412
+ Returns:
413
+ Energy exported to grid today in kWh, or 0.0 if no data.
414
+ """
415
+ if self._energy is None:
416
+ return 0.0
417
+ from pylxpweb.constants import scale_energy_value
418
+
419
+ return scale_energy_value("todayExport", self._energy.todayExport, to_kwh=True)
420
+
421
+ @property
422
+ def energy_today_usage(self) -> float:
423
+ """Get energy consumption today in kWh.
424
+
425
+ Returns:
426
+ Energy consumed by loads today in kWh, or 0.0 if no data.
427
+ """
428
+ if self._energy is None:
429
+ return 0.0
430
+ from pylxpweb.constants import scale_energy_value
431
+
432
+ return scale_energy_value("todayUsage", self._energy.todayUsage, to_kwh=True)
433
+
434
+ @property
435
+ def energy_lifetime_charging(self) -> float:
436
+ """Get total battery charging energy lifetime in kWh.
437
+
438
+ Returns:
439
+ Total energy charged to battery lifetime in kWh, or 0.0 if no data.
440
+ """
441
+ if self._energy is None:
442
+ return 0.0
443
+ from pylxpweb.constants import scale_energy_value
444
+
445
+ return scale_energy_value("totalCharging", self._energy.totalCharging, to_kwh=True)
446
+
447
+ @property
448
+ def energy_lifetime_discharging(self) -> float:
449
+ """Get total battery discharging energy lifetime in kWh.
450
+
451
+ Returns:
452
+ Total energy discharged from battery lifetime in kWh, or 0.0 if no data.
453
+ """
454
+ if self._energy is None:
455
+ return 0.0
456
+ from pylxpweb.constants import scale_energy_value
457
+
458
+ return scale_energy_value("totalDischarging", self._energy.totalDischarging, to_kwh=True)
459
+
460
+ @property
461
+ def energy_lifetime_import(self) -> float:
462
+ """Get total grid import energy lifetime in kWh.
463
+
464
+ Returns:
465
+ Total energy imported from grid lifetime in kWh, or 0.0 if no data.
466
+ """
467
+ if self._energy is None:
468
+ return 0.0
469
+ from pylxpweb.constants import scale_energy_value
470
+
471
+ return scale_energy_value("totalImport", self._energy.totalImport, to_kwh=True)
472
+
473
+ @property
474
+ def energy_lifetime_export(self) -> float:
475
+ """Get total grid export energy lifetime in kWh.
476
+
477
+ Returns:
478
+ Total energy exported to grid lifetime in kWh, or 0.0 if no data.
479
+ """
480
+ if self._energy is None:
481
+ return 0.0
482
+ from pylxpweb.constants import scale_energy_value
483
+
484
+ return scale_energy_value("totalExport", self._energy.totalExport, to_kwh=True)
485
+
486
+ @property
487
+ def energy_lifetime_usage(self) -> float:
488
+ """Get total energy consumption lifetime in kWh.
489
+
490
+ Returns:
491
+ Total energy consumed by loads lifetime in kWh, or 0.0 if no data.
492
+ """
493
+ if self._energy is None:
494
+ return 0.0
495
+ from pylxpweb.constants import scale_energy_value
496
+
497
+ return scale_energy_value("totalUsage", self._energy.totalUsage, to_kwh=True)
498
+
499
+ async def _update_battery_bank(self, battery_info: Any) -> None:
500
+ """Update battery bank object from API data.
501
+
502
+ Args:
503
+ battery_info: BatteryInfo object from API with aggregate data
504
+ """
505
+ from ..battery_bank import BatteryBank
506
+
507
+ # Create or update battery bank with aggregate data
508
+ if self._battery_bank is None:
509
+ self._battery_bank = BatteryBank(
510
+ client=self._client,
511
+ inverter_serial=self.serial_number,
512
+ battery_info=battery_info,
513
+ )
514
+ else:
515
+ # Update existing battery bank data
516
+ self._battery_bank.data = battery_info
517
+
518
+ async def _update_batteries(self, battery_modules: list[Any]) -> None:
519
+ """Update battery objects from API data.
520
+
521
+ Args:
522
+ battery_modules: List of BatteryModule objects from API
523
+ """
524
+ from ..battery import Battery
525
+
526
+ # Batteries are stored in battery_bank, not directly on inverter
527
+ if self._battery_bank is None:
528
+ return
529
+
530
+ # Create Battery objects for each module
531
+ # Use batteryKey to match existing batteries or create new ones
532
+ battery_map = {b.battery_key: b for b in self._battery_bank.batteries}
533
+ updated_batteries = []
534
+
535
+ for module in battery_modules:
536
+ battery_key = module.batteryKey
537
+
538
+ # Reuse existing Battery object or create new one
539
+ if battery_key in battery_map:
540
+ battery = battery_map[battery_key]
541
+ battery.data = module # Update data
542
+ else:
543
+ battery = Battery(client=self._client, battery_data=module)
544
+
545
+ updated_batteries.append(battery)
546
+
547
+ self._battery_bank.batteries = updated_batteries
548
+
549
+ # ============================================================================
550
+ # Control Operations - Universal inverter controls
551
+ # ============================================================================
552
+
553
+ async def read_parameters(
554
+ self, start_register: int = 0, point_number: int = 127
555
+ ) -> dict[str, Any]:
556
+ """Read configuration parameters from inverter.
557
+
558
+ .. deprecated:: 0.3.0
559
+ Use :meth:`refresh(include_parameters=True) <refresh>` to populate
560
+ the :attr:`parameters` property, then access parameters directly
561
+ from :attr:`parameters` or via property accessors like
562
+ :attr:`ac_charge_power_limit`.
563
+
564
+ Args:
565
+ start_register: Starting register address
566
+ point_number: Number of registers to read
567
+
568
+ Returns:
569
+ Dictionary of parameter name to value mappings
570
+
571
+ Example:
572
+ >>> # OLD (deprecated):
573
+ >>> params = await inverter.read_parameters(21, 1)
574
+ >>> params["FUNC_SET_TO_STANDBY"]
575
+ True
576
+ >>>
577
+ >>> # NEW (recommended):
578
+ >>> await inverter.refresh(include_parameters=True)
579
+ >>> inverter.parameters["FUNC_SET_TO_STANDBY"]
580
+ True
581
+ """
582
+ import warnings
583
+
584
+ warnings.warn(
585
+ "read_parameters() is deprecated. Use refresh(include_parameters=True) "
586
+ "to populate the 'parameters' property, then access via inverter.parameters "
587
+ "or property accessors like inverter.ac_charge_power_limit.",
588
+ DeprecationWarning,
589
+ stacklevel=2,
590
+ )
591
+
592
+ response = await self._client.api.control.read_parameters(
593
+ self.serial_number, start_register, point_number
594
+ )
595
+ return response.parameters
596
+
597
+ async def write_parameters(self, parameters: dict[int, int]) -> bool:
598
+ """Write configuration parameters to inverter.
599
+
600
+ Args:
601
+ parameters: Dict of register address to value
602
+
603
+ Returns:
604
+ True if successful
605
+
606
+ Example:
607
+ >>> # Set register 21 bit 9 to enable (standby off)
608
+ >>> await inverter.write_parameters({21: 512}) # Bit 9 set
609
+ """
610
+ response = await self._client.api.control.write_parameters(self.serial_number, parameters)
611
+
612
+ # Invalidate parameter cache on successful write
613
+ if response.success:
614
+ self._parameters_cache_time = None
615
+
616
+ return response.success
617
+
618
+ def _get_parameter(
619
+ self,
620
+ key: str,
621
+ default: int | float | bool = 0,
622
+ cast: type[int] | type[float] | type[bool] = int,
623
+ ) -> int | float | bool | None:
624
+ """Get parameter value from cache with default and type casting.
625
+
626
+ This method reads from the cached `self.parameters` dictionary, which is
627
+ populated by `refresh(include_parameters=True)` with a 1-hour TTL.
628
+
629
+ **NO API CALLS ARE MADE** - this is purely a cache lookup.
630
+
631
+ The cache is automatically refreshed on parameter writes and can be
632
+ manually invalidated via `self._parameters_cache_time = None`.
633
+
634
+ Helper method to:
635
+ - Reduce code repetition in property accessors
636
+ - Provide consistent default handling
637
+ - Enable type-safe parameter access
638
+ - Support model-specific overrides (for inverters with different mappings)
639
+
640
+ Args:
641
+ key: Parameter key name (e.g., "HOLD_AC_CHARGE_POWER_CMD")
642
+ default: Default value if parameter not found or cache is empty
643
+ cast: Type to cast the value to (int, float, or bool)
644
+
645
+ Returns:
646
+ Parameter value cast to specified type, default if not found,
647
+ or None if parameters haven't been loaded yet
648
+
649
+ Note:
650
+ Subclasses can override this method to map standard parameter names
651
+ to model-specific names if needed for different inverter types.
652
+
653
+ Example:
654
+ >>> # Cache hit (no API call)
655
+ >>> self._get_parameter("HOLD_AC_CHARGE_POWER_CMD", 0.0, float)
656
+ 5.0
657
+ >>> self._get_parameter("FUNC_EPS_EN", False, bool)
658
+ True
659
+ """
660
+ if self.parameters is None:
661
+ return None
662
+
663
+ value = self.parameters.get(key, default)
664
+
665
+ # Handle bool explicitly since bool(0) is False but we want the actual bool value
666
+ if cast is bool and isinstance(value, bool):
667
+ return value
668
+
669
+ return cast(value) if value is not None else cast(default)
670
+
671
+ async def set_standby_mode(self, standby: bool) -> bool:
672
+ """Enable or disable standby mode.
673
+
674
+ Universal control: All inverters support standby mode.
675
+
676
+ Args:
677
+ standby: True to enter standby (power off), False for normal operation
678
+
679
+ Returns:
680
+ True if successful
681
+
682
+ Example:
683
+ >>> await inverter.set_standby_mode(False) # Power on
684
+ True
685
+ """
686
+ from pylxpweb.constants import FUNC_EN_BIT_SET_TO_STANDBY, FUNC_EN_REGISTER
687
+
688
+ # Read current function enable register
689
+ params = await self.read_parameters(FUNC_EN_REGISTER, 1)
690
+ current_value = params.get(f"reg_{FUNC_EN_REGISTER}", 0)
691
+
692
+ # Bit logic: 0=Standby, 1=Power On (inverse of parameter)
693
+ if standby:
694
+ # Clear bit 9 to enter standby
695
+ new_value = current_value & ~(1 << FUNC_EN_BIT_SET_TO_STANDBY)
696
+ else:
697
+ # Set bit 9 to power on
698
+ new_value = current_value | (1 << FUNC_EN_BIT_SET_TO_STANDBY)
699
+
700
+ result = await self.write_parameters({FUNC_EN_REGISTER: new_value})
701
+
702
+ # Invalidate parameter cache on successful write
703
+ if result:
704
+ self._parameters_cache_time = None
705
+
706
+ return result
707
+
708
+ @property
709
+ def battery_soc_limits(self) -> dict[str, int] | None:
710
+ """Get battery SOC discharge limits from cached parameters.
711
+
712
+ Universal control: All inverters have SOC limits.
713
+
714
+ Returns:
715
+ Dictionary with on_grid_limit and off_grid_limit (0-100%),
716
+ or None if parameters haven't been loaded yet
717
+
718
+ Example:
719
+ >>> limits = inverter.battery_soc_limits
720
+ >>> limits
721
+ {'on_grid_limit': 10, 'off_grid_limit': 20}
722
+ """
723
+ on_grid = self._get_parameter("HOLD_DISCHG_CUT_OFF_SOC_EOD", 10, int)
724
+ off_grid = self._get_parameter("HOLD_SOC_LOW_LIMIT_EPS_DISCHG", 10, int)
725
+
726
+ if on_grid is None or off_grid is None:
727
+ return None
728
+
729
+ return {
730
+ "on_grid_limit": int(on_grid),
731
+ "off_grid_limit": int(off_grid),
732
+ }
733
+
734
+ async def set_battery_soc_limits(
735
+ self, on_grid_limit: int | None = None, off_grid_limit: int | None = None
736
+ ) -> bool:
737
+ """Set battery SOC discharge limits.
738
+
739
+ Universal control: All inverters have SOC protection.
740
+
741
+ Args:
742
+ on_grid_limit: On-grid discharge cutoff SOC (10-90%)
743
+ off_grid_limit: Off-grid/EPS discharge cutoff SOC (0-100%)
744
+
745
+ Returns:
746
+ True if successful
747
+
748
+ Example:
749
+ >>> await inverter.set_battery_soc_limits(on_grid_limit=15, off_grid_limit=20)
750
+ True
751
+ """
752
+ # Write each parameter individually using parameter names
753
+ success = True
754
+
755
+ if on_grid_limit is not None:
756
+ if not 10 <= on_grid_limit <= 90:
757
+ raise ValueError("on_grid_limit must be between 10 and 90%")
758
+ result = await self._client.api.control.write_parameter(
759
+ self.serial_number,
760
+ "HOLD_DISCHG_CUT_OFF_SOC_EOD",
761
+ str(on_grid_limit),
762
+ )
763
+ success = success and result.success
764
+
765
+ if off_grid_limit is not None:
766
+ if not SOC_MIN_PERCENT <= off_grid_limit <= SOC_MAX_PERCENT:
767
+ raise ValueError(
768
+ f"off_grid_limit must be between {SOC_MIN_PERCENT} and {SOC_MAX_PERCENT}%"
769
+ )
770
+ result = await self._client.api.control.write_parameter(
771
+ self.serial_number,
772
+ "HOLD_SOC_LOW_LIMIT_EPS_DISCHG",
773
+ str(off_grid_limit),
774
+ )
775
+ success = success and result.success
776
+
777
+ # Invalidate parameter cache on successful write
778
+ if success:
779
+ self._parameters_cache_time = None
780
+
781
+ return success
782
+
783
+ # ============================================================================
784
+ # Battery Backup Control (Issue #8)
785
+ # ============================================================================
786
+
787
+ async def enable_battery_backup(self) -> bool:
788
+ """Enable battery backup (EPS) mode.
789
+
790
+ Universal control: All inverters support EPS mode.
791
+
792
+ Returns:
793
+ True if successful
794
+
795
+ Example:
796
+ >>> await inverter.enable_battery_backup()
797
+ True
798
+ """
799
+ result = await self._client.api.control.enable_battery_backup(self.serial_number)
800
+ return result.success
801
+
802
+ async def disable_battery_backup(self) -> bool:
803
+ """Disable battery backup (EPS) mode.
804
+
805
+ Universal control: All inverters support EPS mode.
806
+
807
+ Returns:
808
+ True if successful
809
+
810
+ Example:
811
+ >>> await inverter.disable_battery_backup()
812
+ True
813
+ """
814
+ result = await self._client.api.control.disable_battery_backup(self.serial_number)
815
+ return result.success
816
+
817
+ async def get_battery_backup_status(self) -> bool:
818
+ """Get current battery backup (EPS) mode status.
819
+
820
+ Universal control: All inverters support EPS mode.
821
+
822
+ Returns:
823
+ True if EPS mode is enabled, False otherwise
824
+
825
+ Example:
826
+ >>> is_enabled = await inverter.get_battery_backup_status()
827
+ >>> is_enabled
828
+ True
829
+ """
830
+ return await self._client.api.control.get_battery_backup_status(self.serial_number)
831
+
832
+ async def enable_battery_backup_ctrl(self) -> bool:
833
+ """Enable battery backup control mode (working mode).
834
+
835
+ This controls FUNC_BATTERY_BACKUP_CTRL, which is distinct from
836
+ enable_battery_backup() which controls FUNC_EPS_EN (EPS/off-grid mode).
837
+
838
+ Battery backup control is a working mode setting that affects how
839
+ the inverter manages battery reserves for backup power.
840
+
841
+ Universal control: All inverters support this working mode.
842
+
843
+ Returns:
844
+ True if successful
845
+
846
+ Example:
847
+ >>> await inverter.enable_battery_backup_ctrl()
848
+ True
849
+ """
850
+ result = await self._client.api.control.enable_battery_backup_ctrl(self.serial_number)
851
+ return result.success
852
+
853
+ async def disable_battery_backup_ctrl(self) -> bool:
854
+ """Disable battery backup control mode (working mode).
855
+
856
+ This controls FUNC_BATTERY_BACKUP_CTRL, which is distinct from
857
+ disable_battery_backup() which controls FUNC_EPS_EN (EPS/off-grid mode).
858
+
859
+ Battery backup control is a working mode setting that affects how
860
+ the inverter manages battery reserves for backup power.
861
+
862
+ Universal control: All inverters support this working mode.
863
+
864
+ Returns:
865
+ True if successful
866
+
867
+ Example:
868
+ >>> await inverter.disable_battery_backup_ctrl()
869
+ True
870
+ """
871
+ result = await self._client.api.control.disable_battery_backup_ctrl(self.serial_number)
872
+ return result.success
873
+
874
+ # ============================================================================
875
+ # Green Mode Control (Off-Grid Mode in Web Monitor)
876
+ # ============================================================================
877
+
878
+ async def enable_green_mode(self) -> bool:
879
+ """Enable green mode (off-grid mode in the web monitoring display).
880
+
881
+ Green Mode controls the off-grid operating mode toggle visible in the
882
+ EG4 web monitoring interface. When enabled, the inverter operates in
883
+ an off-grid optimized configuration.
884
+
885
+ Note: This is FUNC_GREEN_EN in register 110, distinct from FUNC_EPS_EN
886
+ (battery backup/EPS mode) in register 21.
887
+
888
+ Universal control: All inverters support green mode.
889
+
890
+ Returns:
891
+ True if successful
892
+
893
+ Example:
894
+ >>> await inverter.enable_green_mode()
895
+ True
896
+ """
897
+ result = await self._client.api.control.enable_green_mode(self.serial_number)
898
+ return result.success
899
+
900
+ async def disable_green_mode(self) -> bool:
901
+ """Disable green mode (off-grid mode in the web monitoring display).
902
+
903
+ Green Mode controls the off-grid operating mode toggle visible in the
904
+ EG4 web monitoring interface. When disabled, the inverter operates in
905
+ standard grid-tied configuration.
906
+
907
+ Note: This is FUNC_GREEN_EN in register 110, distinct from FUNC_EPS_EN
908
+ (battery backup/EPS mode) in register 21.
909
+
910
+ Universal control: All inverters support green mode.
911
+
912
+ Returns:
913
+ True if successful
914
+
915
+ Example:
916
+ >>> await inverter.disable_green_mode()
917
+ True
918
+ """
919
+ result = await self._client.api.control.disable_green_mode(self.serial_number)
920
+ return result.success
921
+
922
+ async def get_green_mode_status(self) -> bool:
923
+ """Get current green mode (off-grid mode) status.
924
+
925
+ Green Mode controls the off-grid operating mode toggle visible in the
926
+ EG4 web monitoring interface.
927
+
928
+ Universal control: All inverters support green mode.
929
+
930
+ Returns:
931
+ True if green mode is enabled, False otherwise
932
+
933
+ Example:
934
+ >>> is_enabled = await inverter.get_green_mode_status()
935
+ >>> is_enabled
936
+ True
937
+ """
938
+ return await self._client.api.control.get_green_mode_status(self.serial_number)
939
+
940
+ @property
941
+ def green_mode_enabled(self) -> bool | None:
942
+ """Get green mode status from cached parameters.
943
+
944
+ Green Mode controls the off-grid operating mode toggle visible in the
945
+ EG4 web monitoring interface.
946
+
947
+ Returns:
948
+ True if green mode is enabled, False if disabled,
949
+ or None if parameters not loaded
950
+
951
+ Example:
952
+ >>> enabled = inverter.green_mode_enabled
953
+ >>> enabled
954
+ True
955
+ """
956
+ value = self._get_parameter("FUNC_GREEN_EN", False, bool)
957
+ return bool(value) if value is not None else None
958
+
959
+ # ============================================================================
960
+ # AC Charge Power Control (Issue #9)
961
+ # ============================================================================
962
+
963
+ async def set_ac_charge_power(self, power_kw: float) -> bool:
964
+ """Set AC charge power limit.
965
+
966
+ Universal control: All inverters support AC charging.
967
+
968
+ Args:
969
+ power_kw: Power limit in kilowatts (0.0 to 15.0)
970
+
971
+ Returns:
972
+ True if successful
973
+
974
+ Raises:
975
+ ValueError: If power_kw is out of valid range
976
+
977
+ Example:
978
+ >>> await inverter.set_ac_charge_power(5.0)
979
+ True
980
+ """
981
+ if not 0.0 <= power_kw <= 15.0:
982
+ raise ValueError(f"AC charge power must be between 0.0 and 15.0 kW, got {power_kw}")
983
+
984
+ # API accepts kW values directly
985
+ result = await self._client.api.control.write_parameter(
986
+ self.serial_number, "HOLD_AC_CHARGE_POWER_CMD", str(power_kw)
987
+ )
988
+
989
+ # Invalidate parameter cache on successful write
990
+ if result.success:
991
+ self._parameters_cache_time = None
992
+
993
+ return result.success
994
+
995
+ @property
996
+ def ac_charge_power_limit(self) -> float | None:
997
+ """Get current AC charge power limit from cached parameters.
998
+
999
+ Universal control: All inverters support AC charging.
1000
+
1001
+ Returns:
1002
+ Current power limit in kilowatts, or None if parameters not loaded
1003
+
1004
+ Example:
1005
+ >>> power = inverter.ac_charge_power_limit
1006
+ >>> power
1007
+ 5.0
1008
+ """
1009
+ value = self._get_parameter("HOLD_AC_CHARGE_POWER_CMD", 0.0, float)
1010
+ return float(value) if value is not None else None
1011
+
1012
+ # ============================================================================
1013
+ # PV Charge Power Control (Issue #10)
1014
+ # ============================================================================
1015
+
1016
+ async def set_pv_charge_power(self, power_kw: int) -> bool:
1017
+ """Set PV (forced) charge power limit.
1018
+
1019
+ Universal control: All inverters support PV charging.
1020
+
1021
+ Args:
1022
+ power_kw: Power limit in kilowatts (0 to 15, integer values only)
1023
+
1024
+ Returns:
1025
+ True if successful
1026
+
1027
+ Raises:
1028
+ ValueError: If power_kw is out of valid range
1029
+
1030
+ Example:
1031
+ >>> await inverter.set_pv_charge_power(10)
1032
+ True
1033
+ """
1034
+ if not 0 <= power_kw <= 15:
1035
+ raise ValueError(f"PV charge power must be between 0 and 15 kW, got {power_kw}")
1036
+
1037
+ # API accepts integer kW values directly
1038
+ result = await self._client.api.control.write_parameter(
1039
+ self.serial_number, "HOLD_FORCED_CHG_POWER_CMD", str(power_kw)
1040
+ )
1041
+
1042
+ # Invalidate parameter cache on successful write
1043
+ if result.success:
1044
+ self._parameters_cache_time = None
1045
+
1046
+ return result.success
1047
+
1048
+ @property
1049
+ def pv_charge_power_limit(self) -> int | None:
1050
+ """Get current PV (forced) charge power limit from cached parameters.
1051
+
1052
+ Universal control: All inverters support PV charging.
1053
+
1054
+ Returns:
1055
+ Current power limit in kilowatts (integer), or None if parameters not loaded
1056
+
1057
+ Example:
1058
+ >>> power = inverter.pv_charge_power_limit
1059
+ >>> power
1060
+ 10
1061
+ """
1062
+ value = self._get_parameter("HOLD_FORCED_CHG_POWER_CMD", 0, int)
1063
+ return int(value) if value is not None else None
1064
+
1065
+ # ============================================================================
1066
+ # Grid Peak Shaving Control (Issue #11)
1067
+ # ============================================================================
1068
+
1069
+ async def set_grid_peak_shaving_power(self, power_kw: float) -> bool:
1070
+ """Set grid peak shaving power limit.
1071
+
1072
+ Universal control: Most inverters support peak shaving.
1073
+
1074
+ Args:
1075
+ power_kw: Power limit in kilowatts (0.0 to 25.5)
1076
+
1077
+ Returns:
1078
+ True if successful
1079
+
1080
+ Raises:
1081
+ ValueError: If power_kw is out of valid range
1082
+
1083
+ Example:
1084
+ >>> await inverter.set_grid_peak_shaving_power(7.0)
1085
+ True
1086
+ """
1087
+ if not 0.0 <= power_kw <= 25.5:
1088
+ raise ValueError(
1089
+ f"Grid peak shaving power must be between 0.0 and 25.5 kW, got {power_kw}"
1090
+ )
1091
+
1092
+ # API accepts kW values directly
1093
+ result = await self._client.api.control.write_parameter(
1094
+ self.serial_number, "_12K_HOLD_GRID_PEAK_SHAVING_POWER", str(power_kw)
1095
+ )
1096
+
1097
+ # Invalidate parameter cache on successful write
1098
+ if result.success:
1099
+ self._parameters_cache_time = None
1100
+
1101
+ return result.success
1102
+
1103
+ @property
1104
+ def grid_peak_shaving_power_limit(self) -> float | None:
1105
+ """Get current grid peak shaving power limit from cached parameters.
1106
+
1107
+ Universal control: Most inverters support peak shaving.
1108
+
1109
+ Returns:
1110
+ Current power limit in kilowatts, or None if parameters not loaded
1111
+
1112
+ Example:
1113
+ >>> power = inverter.grid_peak_shaving_power_limit
1114
+ >>> power
1115
+ 7.0
1116
+ """
1117
+ value = self._get_parameter("_12K_HOLD_GRID_PEAK_SHAVING_POWER", 0.0, float)
1118
+ return float(value) if value is not None else None
1119
+
1120
+ # ============================================================================
1121
+ # AC Charge SOC Limit Control (Issue #12)
1122
+ # ============================================================================
1123
+
1124
+ async def set_ac_charge_soc_limit(self, soc_percent: int) -> bool:
1125
+ """Set AC charge stop SOC limit (when to stop AC charging).
1126
+
1127
+ Universal control: All inverters support AC charge SOC limits.
1128
+
1129
+ Args:
1130
+ soc_percent: SOC percentage (0 to 100)
1131
+
1132
+ Returns:
1133
+ True if successful
1134
+
1135
+ Raises:
1136
+ ValueError: If soc_percent is out of valid range (0-100)
1137
+
1138
+ Example:
1139
+ >>> await inverter.set_ac_charge_soc_limit(90)
1140
+ True
1141
+ """
1142
+ if not 0 <= soc_percent <= 100:
1143
+ raise ValueError(f"AC charge SOC limit must be between 0 and 100%, got {soc_percent}")
1144
+
1145
+ result = await self._client.api.control.write_parameter(
1146
+ self.serial_number, "HOLD_AC_CHARGE_SOC_LIMIT", str(soc_percent)
1147
+ )
1148
+
1149
+ # Invalidate parameter cache on successful write
1150
+ if result.success:
1151
+ self._parameters_cache_time = None
1152
+
1153
+ return result.success
1154
+
1155
+ @property
1156
+ def ac_charge_soc_limit(self) -> int | None:
1157
+ """Get current AC charge stop SOC limit from cached parameters.
1158
+
1159
+ Universal control: All inverters support AC charge SOC limits.
1160
+
1161
+ Returns:
1162
+ Current SOC limit percentage (0-100), or None if parameters not loaded
1163
+ or parameter not found
1164
+
1165
+ Example:
1166
+ >>> limit = inverter.ac_charge_soc_limit
1167
+ >>> limit
1168
+ 90
1169
+ """
1170
+ if self.parameters is None:
1171
+ return None
1172
+ value = self.parameters.get("HOLD_AC_CHARGE_SOC_LIMIT")
1173
+ if value is None:
1174
+ return None
1175
+ try:
1176
+ int_value = int(value)
1177
+ return int_value if 0 <= int_value <= 100 else None
1178
+ except (ValueError, TypeError):
1179
+ return None
1180
+
1181
+ @property
1182
+ def system_charge_soc_limit(self) -> int | None:
1183
+ """Get current system charge SOC limit from cached parameters.
1184
+
1185
+ This controls when the battery stops charging:
1186
+ - 0-100%: Stop charging when battery reaches this SOC
1187
+ - 101%: Enable top balancing (full charge with cell balancing)
1188
+
1189
+ Universal control: All inverters support system charge SOC limits.
1190
+
1191
+ Returns:
1192
+ Current SOC limit percentage (0-101), or None if parameters not loaded
1193
+ or parameter not found
1194
+
1195
+ Example:
1196
+ >>> limit = inverter.system_charge_soc_limit
1197
+ >>> limit
1198
+ 80
1199
+ """
1200
+ if self.parameters is None:
1201
+ return None
1202
+ value = self.parameters.get("HOLD_SYSTEM_CHARGE_SOC_LIMIT")
1203
+ if value is None:
1204
+ return None
1205
+ try:
1206
+ int_value = int(value)
1207
+ return int_value if 0 <= int_value <= 101 else None
1208
+ except (ValueError, TypeError):
1209
+ return None
1210
+
1211
+ # ============================================================================
1212
+ # Battery Current Control (Issue #13)
1213
+ # ============================================================================
1214
+
1215
+ async def set_battery_charge_current(self, current_amps: int) -> bool:
1216
+ """Set battery charge current limit.
1217
+
1218
+ Universal control: All inverters support charge current limits.
1219
+
1220
+ Args:
1221
+ current_amps: Current limit in amperes (0 to 250)
1222
+
1223
+ Returns:
1224
+ True if successful
1225
+
1226
+ Raises:
1227
+ ValueError: If current_amps is out of valid range
1228
+
1229
+ Example:
1230
+ >>> await inverter.set_battery_charge_current(100)
1231
+ True
1232
+ """
1233
+ result = await self._client.api.control.set_battery_charge_current(
1234
+ self.serial_number, current_amps
1235
+ )
1236
+
1237
+ # Invalidate parameter cache on successful write
1238
+ if result.success:
1239
+ self._parameters_cache_time = None
1240
+
1241
+ return result.success
1242
+
1243
+ async def set_battery_discharge_current(self, current_amps: int) -> bool:
1244
+ """Set battery discharge current limit.
1245
+
1246
+ Universal control: All inverters support discharge current limits.
1247
+
1248
+ Args:
1249
+ current_amps: Current limit in amperes (0 to 250)
1250
+
1251
+ Returns:
1252
+ True if successful
1253
+
1254
+ Raises:
1255
+ ValueError: If current_amps is out of valid range
1256
+
1257
+ Example:
1258
+ >>> await inverter.set_battery_discharge_current(120)
1259
+ True
1260
+ """
1261
+ result = await self._client.api.control.set_battery_discharge_current(
1262
+ self.serial_number, current_amps
1263
+ )
1264
+
1265
+ # Invalidate parameter cache on successful write
1266
+ if result.success:
1267
+ self._parameters_cache_time = None
1268
+
1269
+ return result.success
1270
+
1271
+ @property
1272
+ def battery_charge_current_limit(self) -> int | None:
1273
+ """Get current battery charge current limit from cached parameters.
1274
+
1275
+ Universal control: All inverters support charge current limits.
1276
+
1277
+ Returns:
1278
+ Current limit in amperes, or None if parameters not loaded
1279
+
1280
+ Example:
1281
+ >>> current = inverter.battery_charge_current_limit
1282
+ >>> current
1283
+ 100
1284
+ """
1285
+ value = self._get_parameter("HOLD_LEAD_ACID_CHARGE_RATE", 0, int)
1286
+ return int(value) if value is not None else None
1287
+
1288
+ @property
1289
+ def battery_discharge_current_limit(self) -> int | None:
1290
+ """Get current battery discharge current limit from cached parameters.
1291
+
1292
+ Universal control: All inverters support discharge current limits.
1293
+
1294
+ Returns:
1295
+ Current limit in amperes, or None if parameters not loaded
1296
+
1297
+ Example:
1298
+ >>> current = inverter.battery_discharge_current_limit
1299
+ >>> current
1300
+ 120
1301
+ """
1302
+ value = self._get_parameter("HOLD_LEAD_ACID_DISCHARGE_RATE", 0, int)
1303
+ return int(value) if value is not None else None
1304
+
1305
+ # ============================================================================
1306
+ # Discharge Power Control
1307
+ # ============================================================================
1308
+
1309
+ @property
1310
+ def discharge_power_limit(self) -> int | None:
1311
+ """Get current discharge power limit from cached parameters.
1312
+
1313
+ Universal control: All inverters support discharge power limits.
1314
+
1315
+ Returns:
1316
+ Discharge power limit as percentage (0-100%), or None if not loaded
1317
+ or parameter not found
1318
+
1319
+ Example:
1320
+ >>> power = inverter.discharge_power_limit
1321
+ >>> power
1322
+ 100
1323
+ """
1324
+ if self.parameters is None:
1325
+ return None
1326
+ value = self.parameters.get("HOLD_DISCHG_POWER_PERCENT_CMD")
1327
+ if value is None:
1328
+ return None
1329
+ try:
1330
+ int_value = int(value)
1331
+ return int_value if 0 <= int_value <= 100 else None
1332
+ except (ValueError, TypeError):
1333
+ return None
1334
+
1335
+ # ============================================================================
1336
+ # Battery Voltage Limits
1337
+ # ============================================================================
1338
+
1339
+ @property
1340
+ def battery_voltage_limits(self) -> dict[str, float] | None:
1341
+ """Get battery voltage limits from cached parameters.
1342
+
1343
+ Universal control: All inverters have battery voltage protection.
1344
+
1345
+ Returns:
1346
+ Dictionary with voltage limits in volts, or None if not loaded:
1347
+ - max_charge_voltage: Maximum charge voltage (V)
1348
+ - min_charge_voltage: Minimum charge voltage (V)
1349
+ - max_discharge_voltage: Maximum discharge voltage (V)
1350
+ - min_discharge_voltage: Minimum discharge voltage (V)
1351
+
1352
+ Example:
1353
+ >>> limits = inverter.battery_voltage_limits
1354
+ >>> limits
1355
+ {'max_charge_voltage': 58.4, 'min_charge_voltage': 48.0,
1356
+ 'max_discharge_voltage': 57.6, 'min_discharge_voltage': 46.0}
1357
+ """
1358
+ # Return None if parameters not loaded yet
1359
+ if self.parameters is None:
1360
+ return None
1361
+
1362
+ # Check if all required params are present
1363
+ required_keys = [
1364
+ "HOLD_BAT_VOLT_MAX_CHG",
1365
+ "HOLD_BAT_VOLT_MIN_CHG",
1366
+ "HOLD_BAT_VOLT_MAX_DISCHG",
1367
+ "HOLD_BAT_VOLT_MIN_DISCHG",
1368
+ ]
1369
+ if not all(key in self.parameters for key in required_keys):
1370
+ return None
1371
+
1372
+ # Get values directly from parameters dict (already validated as present)
1373
+ # Battery voltage values are stored as V * 100, so divide by 100
1374
+ max_chg = self.parameters.get("HOLD_BAT_VOLT_MAX_CHG", 0)
1375
+ min_chg = self.parameters.get("HOLD_BAT_VOLT_MIN_CHG", 0)
1376
+ max_dischg = self.parameters.get("HOLD_BAT_VOLT_MAX_DISCHG", 0)
1377
+ min_dischg = self.parameters.get("HOLD_BAT_VOLT_MIN_DISCHG", 0)
1378
+
1379
+ return {
1380
+ "max_charge_voltage": float(max_chg) / 100.0,
1381
+ "min_charge_voltage": float(min_chg) / 100.0,
1382
+ "max_discharge_voltage": float(max_dischg) / 100.0,
1383
+ "min_discharge_voltage": float(min_dischg) / 100.0,
1384
+ }
1385
+
1386
+ # ============================================================================
1387
+ # Operating Mode Control (Issue #14)
1388
+ # ============================================================================
1389
+
1390
+ async def set_operating_mode(self, mode: OperatingMode) -> bool:
1391
+ """Set inverter operating mode.
1392
+
1393
+ Valid operating modes:
1394
+ - NORMAL: Normal operation (power on)
1395
+ - STANDBY: Standby mode (power off)
1396
+
1397
+ Note: Quick Charge and Quick Discharge are not operating modes,
1398
+ they are separate functions that can be enabled/disabled independently.
1399
+
1400
+ Args:
1401
+ mode: Operating mode (NORMAL or STANDBY)
1402
+
1403
+ Returns:
1404
+ True if successful
1405
+
1406
+ Example:
1407
+ >>> from pylxpweb.models import OperatingMode
1408
+ >>> await inverter.set_operating_mode(OperatingMode.NORMAL)
1409
+ True
1410
+ >>> await inverter.set_operating_mode(OperatingMode.STANDBY)
1411
+ True
1412
+ """
1413
+ # Import here to avoid circular dependency
1414
+ from pylxpweb.models import OperatingMode as OM
1415
+
1416
+ standby = mode == OM.STANDBY
1417
+ result = await self.set_standby_mode(standby)
1418
+
1419
+ # Invalidate parameter cache on successful write
1420
+ if result:
1421
+ self._parameters_cache_time = None
1422
+
1423
+ return result
1424
+
1425
+ async def get_operating_mode(self) -> OperatingMode:
1426
+ """Get current operating mode.
1427
+
1428
+ Returns:
1429
+ Current operating mode (NORMAL or STANDBY)
1430
+
1431
+ Example:
1432
+ >>> from pylxpweb.models import OperatingMode
1433
+ >>> mode = await inverter.get_operating_mode()
1434
+ >>> mode
1435
+ <OperatingMode.NORMAL: 'normal'>
1436
+ """
1437
+ # Import here to avoid circular dependency
1438
+ from pylxpweb.models import OperatingMode as OM
1439
+
1440
+ # Read FUNC_EN register bit 9 (FUNC_EN_BIT_SET_TO_STANDBY)
1441
+ # 0 = Standby, 1 = Normal (Power On)
1442
+ params = await self.read_parameters(21, 1)
1443
+ func_en = params.get("FUNC_EN_REGISTER", 0)
1444
+
1445
+ # Bit 9: 0=Standby, 1=Normal
1446
+ is_standby = not bool((func_en >> 9) & 1)
1447
+
1448
+ return OM.STANDBY if is_standby else OM.NORMAL
1449
+
1450
+ # ============================================================================
1451
+ # Quick Charge Control (Issue #14)
1452
+ # ============================================================================
1453
+
1454
+ async def enable_quick_charge(self) -> bool:
1455
+ """Enable quick charge function.
1456
+
1457
+ Quick charge is a function control (not an operating mode) that
1458
+ can be active alongside Normal or Standby operating modes.
1459
+
1460
+ Returns:
1461
+ True if successful
1462
+
1463
+ Example:
1464
+ >>> await inverter.enable_quick_charge()
1465
+ True
1466
+ """
1467
+ result = await self._client.api.control.start_quick_charge(self.serial_number)
1468
+ return result.success
1469
+
1470
+ async def disable_quick_charge(self) -> bool:
1471
+ """Disable quick charge function.
1472
+
1473
+ Returns:
1474
+ True if successful
1475
+
1476
+ Example:
1477
+ >>> await inverter.disable_quick_charge()
1478
+ True
1479
+ """
1480
+ result = await self._client.api.control.stop_quick_charge(self.serial_number)
1481
+ return result.success
1482
+
1483
+ async def get_quick_charge_status(self) -> bool:
1484
+ """Get quick charge function status.
1485
+
1486
+ Returns:
1487
+ True if quick charge is active, False otherwise
1488
+
1489
+ Example:
1490
+ >>> is_active = await inverter.get_quick_charge_status()
1491
+ >>> is_active
1492
+ False
1493
+ """
1494
+ status = await self._client.api.control.get_quick_charge_status(self.serial_number)
1495
+ return status.hasUnclosedQuickChargeTask
1496
+
1497
+ # ============================================================================
1498
+ # Quick Discharge Control (Issue #14)
1499
+ # ============================================================================
1500
+
1501
+ async def enable_quick_discharge(self) -> bool:
1502
+ """Enable quick discharge function.
1503
+
1504
+ Quick discharge is a function control (not an operating mode) that
1505
+ can be active alongside Normal or Standby operating modes.
1506
+
1507
+ Note: There is no status endpoint for quick discharge, unlike quick charge.
1508
+
1509
+ Returns:
1510
+ True if successful
1511
+
1512
+ Example:
1513
+ >>> await inverter.enable_quick_discharge()
1514
+ True
1515
+ """
1516
+ result = await self._client.api.control.start_quick_discharge(self.serial_number)
1517
+ return result.success
1518
+
1519
+ async def disable_quick_discharge(self) -> bool:
1520
+ """Disable quick discharge function.
1521
+
1522
+ Returns:
1523
+ True if successful
1524
+
1525
+ Example:
1526
+ >>> await inverter.disable_quick_discharge()
1527
+ True
1528
+ """
1529
+ result = await self._client.api.control.stop_quick_discharge(self.serial_number)
1530
+ return result.success
1531
+
1532
+ async def get_quick_discharge_status(self) -> bool:
1533
+ """Get quick discharge function status.
1534
+
1535
+ Note: Uses the quickCharge/getStatusInfo endpoint which returns status
1536
+ for both quick charge and quick discharge operations.
1537
+
1538
+ Returns:
1539
+ True if quick discharge is active, False otherwise
1540
+
1541
+ Example:
1542
+ >>> is_active = await inverter.get_quick_discharge_status()
1543
+ >>> is_active
1544
+ False
1545
+ """
1546
+ status = await self._client.api.control.get_quick_charge_status(self.serial_number)
1547
+ return status.hasUnclosedQuickDischargeTask
1548
+
1549
+ # ============================================================================
1550
+ # Working Mode Controls (Issue #16)
1551
+ # ============================================================================
1552
+
1553
+ async def enable_ac_charge_mode(self) -> bool:
1554
+ """Enable AC charge mode to allow battery charging from grid.
1555
+
1556
+ Universal control: All inverters support AC charging.
1557
+
1558
+ Returns:
1559
+ True if successful
1560
+
1561
+ Example:
1562
+ >>> await inverter.enable_ac_charge_mode()
1563
+ True
1564
+ """
1565
+ result = await self._client.api.control.enable_ac_charge_mode(self.serial_number)
1566
+ return result.success
1567
+
1568
+ async def disable_ac_charge_mode(self) -> bool:
1569
+ """Disable AC charge mode.
1570
+
1571
+ Universal control: All inverters support AC charging.
1572
+
1573
+ Returns:
1574
+ True if successful
1575
+
1576
+ Example:
1577
+ >>> await inverter.disable_ac_charge_mode()
1578
+ True
1579
+ """
1580
+ result = await self._client.api.control.disable_ac_charge_mode(self.serial_number)
1581
+ return result.success
1582
+
1583
+ async def get_ac_charge_mode_status(self) -> bool:
1584
+ """Get current AC charge mode status.
1585
+
1586
+ Universal control: All inverters support AC charging.
1587
+
1588
+ Returns:
1589
+ True if AC charge mode is enabled, False otherwise
1590
+
1591
+ Example:
1592
+ >>> is_enabled = await inverter.get_ac_charge_mode_status()
1593
+ >>> is_enabled
1594
+ True
1595
+ """
1596
+ return await self._client.api.control.get_ac_charge_mode_status(self.serial_number)
1597
+
1598
+ async def enable_pv_charge_priority(self) -> bool:
1599
+ """Enable PV charge priority mode during specified hours.
1600
+
1601
+ Universal control: All inverters support forced charge.
1602
+
1603
+ Returns:
1604
+ True if successful
1605
+
1606
+ Example:
1607
+ >>> await inverter.enable_pv_charge_priority()
1608
+ True
1609
+ """
1610
+ result = await self._client.api.control.enable_pv_charge_priority(self.serial_number)
1611
+ return result.success
1612
+
1613
+ async def disable_pv_charge_priority(self) -> bool:
1614
+ """Disable PV charge priority mode.
1615
+
1616
+ Universal control: All inverters support forced charge.
1617
+
1618
+ Returns:
1619
+ True if successful
1620
+
1621
+ Example:
1622
+ >>> await inverter.disable_pv_charge_priority()
1623
+ True
1624
+ """
1625
+ result = await self._client.api.control.disable_pv_charge_priority(self.serial_number)
1626
+ return result.success
1627
+
1628
+ async def get_pv_charge_priority_status(self) -> bool:
1629
+ """Get current PV charge priority status.
1630
+
1631
+ Universal control: All inverters support forced charge.
1632
+
1633
+ Returns:
1634
+ True if PV charge priority is enabled, False otherwise
1635
+
1636
+ Example:
1637
+ >>> is_enabled = await inverter.get_pv_charge_priority_status()
1638
+ >>> is_enabled
1639
+ True
1640
+ """
1641
+ return await self._client.api.control.get_pv_charge_priority_status(self.serial_number)
1642
+
1643
+ async def enable_forced_discharge(self) -> bool:
1644
+ """Enable forced discharge mode for grid export.
1645
+
1646
+ Universal control: All inverters support forced discharge.
1647
+
1648
+ Returns:
1649
+ True if successful
1650
+
1651
+ Example:
1652
+ >>> await inverter.enable_forced_discharge()
1653
+ True
1654
+ """
1655
+ result = await self._client.api.control.enable_forced_discharge(self.serial_number)
1656
+ return result.success
1657
+
1658
+ async def disable_forced_discharge(self) -> bool:
1659
+ """Disable forced discharge mode.
1660
+
1661
+ Universal control: All inverters support forced discharge.
1662
+
1663
+ Returns:
1664
+ True if successful
1665
+
1666
+ Example:
1667
+ >>> await inverter.disable_forced_discharge()
1668
+ True
1669
+ """
1670
+ result = await self._client.api.control.disable_forced_discharge(self.serial_number)
1671
+ return result.success
1672
+
1673
+ async def get_forced_discharge_status(self) -> bool:
1674
+ """Get current forced discharge status.
1675
+
1676
+ Universal control: All inverters support forced discharge.
1677
+
1678
+ Returns:
1679
+ True if forced discharge is enabled, False otherwise
1680
+
1681
+ Example:
1682
+ >>> is_enabled = await inverter.get_forced_discharge_status()
1683
+ >>> is_enabled
1684
+ True
1685
+ """
1686
+ return await self._client.api.control.get_forced_discharge_status(self.serial_number)
1687
+
1688
+ async def enable_peak_shaving_mode(self) -> bool:
1689
+ """Enable grid peak shaving mode.
1690
+
1691
+ Universal control: Most inverters support peak shaving.
1692
+
1693
+ Returns:
1694
+ True if successful
1695
+
1696
+ Example:
1697
+ >>> await inverter.enable_peak_shaving_mode()
1698
+ True
1699
+ """
1700
+ result = await self._client.api.control.enable_peak_shaving_mode(self.serial_number)
1701
+ return result.success
1702
+
1703
+ async def disable_peak_shaving_mode(self) -> bool:
1704
+ """Disable grid peak shaving mode.
1705
+
1706
+ Universal control: Most inverters support peak shaving.
1707
+
1708
+ Returns:
1709
+ True if successful
1710
+
1711
+ Example:
1712
+ >>> await inverter.disable_peak_shaving_mode()
1713
+ True
1714
+ """
1715
+ result = await self._client.api.control.disable_peak_shaving_mode(self.serial_number)
1716
+ return result.success
1717
+
1718
+ async def get_peak_shaving_mode_status(self) -> bool:
1719
+ """Get current peak shaving mode status.
1720
+
1721
+ Universal control: Most inverters support peak shaving.
1722
+
1723
+ Returns:
1724
+ True if peak shaving mode is enabled, False otherwise
1725
+
1726
+ Example:
1727
+ >>> is_enabled = await inverter.get_peak_shaving_mode_status()
1728
+ >>> is_enabled
1729
+ True
1730
+ """
1731
+ return await self._client.api.control.get_peak_shaving_mode_status(self.serial_number)
1732
+
1733
+ # ============================================================================
1734
+ # Feature Detection (Model-Based Capabilities)
1735
+ # ============================================================================
1736
+
1737
+ async def detect_features(self, force: bool = False) -> InverterFeatures:
1738
+ """Detect inverter features and capabilities.
1739
+
1740
+ This method uses a multi-layer approach to determine what features
1741
+ are available on this specific inverter:
1742
+
1743
+ 1. **Device Type Code**: Read HOLD_DEVICE_TYPE_CODE (register 19) to
1744
+ identify the model family (SNA, PV Series, LXP-EU, etc.)
1745
+
1746
+ 2. **Model Info**: Decode HOLD_MODEL (registers 0-1) for hardware
1747
+ configuration (power rating, battery type, US/EU version)
1748
+
1749
+ 3. **Family Defaults**: Apply known feature sets for the model family
1750
+
1751
+ 4. **Runtime Probing**: Check for optional registers that may or may
1752
+ not exist on specific firmware versions
1753
+
1754
+ Feature detection results are cached. Use `force=True` to re-detect.
1755
+
1756
+ Args:
1757
+ force: If True, re-detect features even if already cached
1758
+
1759
+ Returns:
1760
+ InverterFeatures with all detected capabilities
1761
+
1762
+ Example:
1763
+ >>> features = await inverter.detect_features()
1764
+ >>> features.model_family
1765
+ <InverterFamily.SNA: 'SNA'>
1766
+ >>> features.split_phase
1767
+ True
1768
+ >>> features.supports_volt_watt_curve
1769
+ False
1770
+ """
1771
+ if self._features_detected and not force:
1772
+ return self._features
1773
+
1774
+ # Ensure parameters are loaded (needed for feature detection)
1775
+ if self.parameters is None:
1776
+ await self._fetch_parameters()
1777
+
1778
+ if self.parameters is None:
1779
+ _LOGGER.warning(
1780
+ "Cannot detect features for %s: parameters not available",
1781
+ self.serial_number,
1782
+ )
1783
+ return self._features
1784
+
1785
+ # Layer 1: Get device type code
1786
+ device_type_code = self.parameters.get("HOLD_DEVICE_TYPE_CODE", 0)
1787
+ if isinstance(device_type_code, str):
1788
+ device_type_code = int(device_type_code)
1789
+
1790
+ # Create features from device type code (applies family defaults)
1791
+ self._features = InverterFeatures.from_device_type_code(device_type_code)
1792
+
1793
+ # Layer 2: Decode model info from HOLD_MODEL_* parameters
1794
+ # The API returns individual decoded fields like HOLD_MODEL_lithiumType
1795
+ self._features.model_info = InverterModelInfo.from_parameters(self.parameters)
1796
+
1797
+ # Layer 3: Runtime probing for optional features
1798
+ await self._probe_optional_features()
1799
+
1800
+ self._features_detected = True
1801
+ _LOGGER.debug(
1802
+ "Detected features for %s: family=%s, grid_type=%s",
1803
+ self.serial_number,
1804
+ self._features.model_family.value,
1805
+ self._features.grid_type.value,
1806
+ )
1807
+
1808
+ return self._features
1809
+
1810
+ async def _probe_optional_features(self) -> None:
1811
+ """Probe for optional features by checking for specific registers.
1812
+
1813
+ This method checks for registers that may or may not be present
1814
+ depending on firmware version or hardware variant.
1815
+ """
1816
+ if self.parameters is None:
1817
+ return
1818
+
1819
+ # Check for SNA-specific registers
1820
+ # SNA models have discharge recovery hysteresis parameters
1821
+ has_recovery_lag = "HOLD_DISCHG_RECOVERY_LAG_SOC" in self.parameters
1822
+ has_quick_charge = "SNA_HOLD_QUICK_CHARGE_MINUTE" in self.parameters
1823
+ if has_recovery_lag or has_quick_charge:
1824
+ self._features.has_sna_registers = True
1825
+ self._features.discharge_recovery_hysteresis = True
1826
+
1827
+ # Check for PV series registers (volt-watt curve parameters)
1828
+ if "_12K_HOLD_GRID_PEAK_SHAVING_POWER" in self.parameters:
1829
+ self._features.has_pv_series_registers = True
1830
+ self._features.grid_peak_shaving = True
1831
+
1832
+ # Check for volt-watt curve support
1833
+ if "HOLD_VW_V1" in self.parameters or "HOLD_VOLT_WATT_V1" in self.parameters:
1834
+ self._features.volt_watt_curve = True
1835
+
1836
+ # Check for DRMS support
1837
+ if "FUNC_DRMS_EN" in self.parameters:
1838
+ drms_val = self.parameters.get("FUNC_DRMS_EN")
1839
+ # DRMS is available if the parameter exists (regardless of value)
1840
+ self._features.drms_support = drms_val is not None
1841
+
1842
+ # ============================================================================
1843
+ # Feature Properties (Read-Only Capability Flags)
1844
+ # ============================================================================
1845
+
1846
+ @property
1847
+ def features(self) -> InverterFeatures:
1848
+ """Get detected inverter features.
1849
+
1850
+ Note: Call `detect_features()` first to populate feature data.
1851
+ If features haven't been detected yet, returns default features.
1852
+
1853
+ Returns:
1854
+ InverterFeatures instance with capability flags
1855
+
1856
+ Example:
1857
+ >>> await inverter.detect_features()
1858
+ >>> inverter.features.split_phase
1859
+ True
1860
+ """
1861
+ return self._features
1862
+
1863
+ @property
1864
+ def model_family(self) -> InverterFamily:
1865
+ """Get the inverter model family.
1866
+
1867
+ Returns:
1868
+ InverterFamily enum value (SNA, PV_SERIES, LXP_EU, etc.)
1869
+
1870
+ Example:
1871
+ >>> await inverter.detect_features()
1872
+ >>> inverter.model_family
1873
+ <InverterFamily.SNA: 'SNA'>
1874
+ """
1875
+ return self._features.model_family
1876
+
1877
+ @property
1878
+ def device_type_code(self) -> int:
1879
+ """Get the device type code from HOLD_DEVICE_TYPE_CODE register.
1880
+
1881
+ This is the firmware-level model identifier that varies per model:
1882
+ - SNA12K-US: 54
1883
+ - 18KPV: 2092
1884
+ - LXP-EU 12K: 12
1885
+
1886
+ Returns:
1887
+ Device type code integer
1888
+
1889
+ Example:
1890
+ >>> await inverter.detect_features()
1891
+ >>> inverter.device_type_code
1892
+ 54
1893
+ """
1894
+ return self._features.device_type_code
1895
+
1896
+ @property
1897
+ def grid_type(self) -> GridType:
1898
+ """Get the grid configuration type.
1899
+
1900
+ Returns:
1901
+ GridType enum value (SPLIT_PHASE, SINGLE_PHASE, THREE_PHASE)
1902
+
1903
+ Example:
1904
+ >>> await inverter.detect_features()
1905
+ >>> inverter.grid_type
1906
+ <GridType.SPLIT_PHASE: 'split_phase'>
1907
+ """
1908
+ return self._features.grid_type
1909
+
1910
+ @property
1911
+ def power_rating_kw(self) -> int:
1912
+ """Get the nominal power rating in kilowatts.
1913
+
1914
+ Decoded from HOLD_MODEL register.
1915
+
1916
+ Returns:
1917
+ Power rating in kW, or 0 if unknown
1918
+
1919
+ Example:
1920
+ >>> await inverter.detect_features()
1921
+ >>> inverter.power_rating_kw
1922
+ 12
1923
+ """
1924
+ return self._features.model_info.power_rating_kw
1925
+
1926
+ @property
1927
+ def is_us_version(self) -> bool:
1928
+ """Check if this is a US market version.
1929
+
1930
+ Decoded from HOLD_MODEL register.
1931
+
1932
+ Returns:
1933
+ True if US version, False for EU/other
1934
+
1935
+ Example:
1936
+ >>> await inverter.detect_features()
1937
+ >>> inverter.is_us_version
1938
+ True
1939
+ """
1940
+ return self._features.model_info.us_version
1941
+
1942
+ @property
1943
+ def supports_split_phase(self) -> bool:
1944
+ """Check if inverter supports split-phase grid configuration.
1945
+
1946
+ Split-phase is the standard US residential configuration (120V/240V).
1947
+
1948
+ Returns:
1949
+ True if split-phase is supported
1950
+
1951
+ Example:
1952
+ >>> await inverter.detect_features()
1953
+ >>> inverter.supports_split_phase
1954
+ True
1955
+ """
1956
+ return self._features.split_phase
1957
+
1958
+ @property
1959
+ def supports_three_phase(self) -> bool:
1960
+ """Check if inverter supports three-phase grid configuration.
1961
+
1962
+ Returns:
1963
+ True if three-phase is supported
1964
+
1965
+ Example:
1966
+ >>> await inverter.detect_features()
1967
+ >>> inverter.supports_three_phase
1968
+ False
1969
+ """
1970
+ return self._features.three_phase_capable
1971
+
1972
+ @property
1973
+ def supports_off_grid(self) -> bool:
1974
+ """Check if inverter supports off-grid (EPS) mode.
1975
+
1976
+ Returns:
1977
+ True if off-grid/EPS mode is supported
1978
+
1979
+ Example:
1980
+ >>> await inverter.detect_features()
1981
+ >>> inverter.supports_off_grid
1982
+ True
1983
+ """
1984
+ return self._features.off_grid_capable
1985
+
1986
+ @property
1987
+ def supports_parallel(self) -> bool:
1988
+ """Check if inverter supports parallel operation with other inverters.
1989
+
1990
+ Returns:
1991
+ True if parallel operation is supported
1992
+
1993
+ Example:
1994
+ >>> await inverter.detect_features()
1995
+ >>> inverter.supports_parallel
1996
+ False
1997
+ """
1998
+ return self._features.parallel_support
1999
+
2000
+ @property
2001
+ def supports_volt_watt_curve(self) -> bool:
2002
+ """Check if inverter supports volt-watt curve settings.
2003
+
2004
+ Returns:
2005
+ True if volt-watt curve is supported
2006
+
2007
+ Example:
2008
+ >>> await inverter.detect_features()
2009
+ >>> inverter.supports_volt_watt_curve
2010
+ False
2011
+ """
2012
+ return self._features.volt_watt_curve
2013
+
2014
+ @property
2015
+ def supports_grid_peak_shaving(self) -> bool:
2016
+ """Check if inverter supports grid peak shaving.
2017
+
2018
+ Returns:
2019
+ True if grid peak shaving is supported
2020
+
2021
+ Example:
2022
+ >>> await inverter.detect_features()
2023
+ >>> inverter.supports_grid_peak_shaving
2024
+ True
2025
+ """
2026
+ return self._features.grid_peak_shaving
2027
+
2028
+ @property
2029
+ def supports_drms(self) -> bool:
2030
+ """Check if inverter supports DRMS (Demand Response Management).
2031
+
2032
+ Returns:
2033
+ True if DRMS is supported
2034
+
2035
+ Example:
2036
+ >>> await inverter.detect_features()
2037
+ >>> inverter.supports_drms
2038
+ False
2039
+ """
2040
+ return self._features.drms_support
2041
+
2042
+ @property
2043
+ def supports_discharge_recovery_hysteresis(self) -> bool:
2044
+ """Check if inverter supports discharge recovery hysteresis settings.
2045
+
2046
+ This feature allows setting SOC/voltage lag values for discharge
2047
+ recovery, preventing oscillation when SOC is near the cutoff threshold.
2048
+
2049
+ SNA series inverters have this feature.
2050
+
2051
+ Returns:
2052
+ True if discharge recovery hysteresis is supported
2053
+
2054
+ Example:
2055
+ >>> await inverter.detect_features()
2056
+ >>> inverter.supports_discharge_recovery_hysteresis
2057
+ True
2058
+ """
2059
+ return self._features.discharge_recovery_hysteresis
2060
+
2061
+ # ============================================================================
2062
+ # Model-Specific Parameter Access
2063
+ # ============================================================================
2064
+
2065
+ @property
2066
+ def discharge_recovery_lag_soc(self) -> int | None:
2067
+ """Get discharge recovery SOC hysteresis value (SNA models only).
2068
+
2069
+ This setting prevents rapid on/off cycling when battery SOC is near
2070
+ the discharge cutoff threshold. The inverter waits until SOC rises
2071
+ by this amount before resuming discharge.
2072
+
2073
+ Returns:
2074
+ SOC hysteresis percentage, or None if not supported/loaded
2075
+
2076
+ Example:
2077
+ >>> await inverter.detect_features()
2078
+ >>> if inverter.supports_discharge_recovery_hysteresis:
2079
+ ... print(f"Lag SOC: {inverter.discharge_recovery_lag_soc}%")
2080
+ """
2081
+ if not self._features.discharge_recovery_hysteresis:
2082
+ return None
2083
+ value = self._get_parameter("HOLD_DISCHG_RECOVERY_LAG_SOC", 0, int)
2084
+ return int(value) if value is not None else None
2085
+
2086
+ @property
2087
+ def discharge_recovery_lag_volt(self) -> float | None:
2088
+ """Get discharge recovery voltage hysteresis value (SNA models only).
2089
+
2090
+ This setting prevents rapid on/off cycling when battery voltage is
2091
+ near the discharge cutoff threshold. The inverter waits until voltage
2092
+ rises by this amount before resuming discharge.
2093
+
2094
+ Returns:
2095
+ Voltage hysteresis in volts, or None if not supported/loaded
2096
+
2097
+ Example:
2098
+ >>> await inverter.detect_features()
2099
+ >>> if inverter.supports_discharge_recovery_hysteresis:
2100
+ ... print(f"Lag Voltage: {inverter.discharge_recovery_lag_volt}V")
2101
+ """
2102
+ if not self._features.discharge_recovery_hysteresis:
2103
+ return None
2104
+ value = self._get_parameter("HOLD_DISCHG_RECOVERY_LAG_VOLT", 0, float)
2105
+ return float(value) / 10.0 if value is not None else None # Scaled by 10
2106
+
2107
+ @property
2108
+ def quick_charge_minute(self) -> int | None:
2109
+ """Get quick charge duration in minutes (SNA models only).
2110
+
2111
+ This setting controls how long quick charge runs when activated.
2112
+
2113
+ Returns:
2114
+ Quick charge duration in minutes, or None if not supported/loaded
2115
+
2116
+ Example:
2117
+ >>> await inverter.detect_features()
2118
+ >>> if inverter.features.quick_charge_minute:
2119
+ ... print(f"Quick charge: {inverter.quick_charge_minute} min")
2120
+ """
2121
+ if not self._features.quick_charge_minute:
2122
+ return None
2123
+ value = self._get_parameter("SNA_HOLD_QUICK_CHARGE_MINUTE", 0, int)
2124
+ return int(value) if value is not None else None