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,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
|