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,192 @@
|
|
|
1
|
+
"""Generic inverter implementation for standard EG4/Luxpower models.
|
|
2
|
+
|
|
3
|
+
This module provides the GenericInverter class that handles all standard
|
|
4
|
+
inverter models including FlexBOSS21, FlexBOSS18, 18KPV, 12KPV, and XP.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from ..models import DeviceClass, Entity, StateClass
|
|
12
|
+
from .base import BaseInverter
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GenericInverter(BaseInverter):
|
|
19
|
+
"""Generic inverter for standard EG4/Luxpower models.
|
|
20
|
+
|
|
21
|
+
Handles all standard inverter models:
|
|
22
|
+
- FlexBOSS21 (21kW)
|
|
23
|
+
- FlexBOSS18 (18kW)
|
|
24
|
+
- 18KPV (18kW)
|
|
25
|
+
- 12KPV (12kW)
|
|
26
|
+
- XP (various power ratings)
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
```python
|
|
30
|
+
inverter = GenericInverter(
|
|
31
|
+
client=client,
|
|
32
|
+
serial_number="1234567890",
|
|
33
|
+
model="FlexBOSS21"
|
|
34
|
+
)
|
|
35
|
+
await inverter.refresh()
|
|
36
|
+
print(f"Power: {inverter.power_output}W")
|
|
37
|
+
print(f"SOC: {inverter.battery_soc}%")
|
|
38
|
+
```
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def to_entities(self) -> list[Entity]:
|
|
42
|
+
"""Generate entities for this inverter.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
List of Entity objects representing sensors for this inverter.
|
|
46
|
+
"""
|
|
47
|
+
entities = []
|
|
48
|
+
|
|
49
|
+
# Power sensors
|
|
50
|
+
if self._runtime:
|
|
51
|
+
# AC Power Output
|
|
52
|
+
entities.append(
|
|
53
|
+
Entity(
|
|
54
|
+
unique_id=f"{self.serial_number}_power",
|
|
55
|
+
name=f"{self.model} {self.serial_number} Power",
|
|
56
|
+
device_class=DeviceClass.POWER,
|
|
57
|
+
state_class=StateClass.MEASUREMENT,
|
|
58
|
+
unit_of_measurement="W",
|
|
59
|
+
value=self.power_output,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Battery SOC
|
|
64
|
+
if self.battery_soc is not None:
|
|
65
|
+
entities.append(
|
|
66
|
+
Entity(
|
|
67
|
+
unique_id=f"{self.serial_number}_soc",
|
|
68
|
+
name=f"{self.model} {self.serial_number} Battery SOC",
|
|
69
|
+
device_class=DeviceClass.BATTERY,
|
|
70
|
+
state_class=StateClass.MEASUREMENT,
|
|
71
|
+
unit_of_measurement="%",
|
|
72
|
+
value=self.battery_soc,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Battery Voltage
|
|
77
|
+
if hasattr(self._runtime, "vBat") and self._runtime.vBat:
|
|
78
|
+
entities.append(
|
|
79
|
+
Entity(
|
|
80
|
+
unique_id=f"{self.serial_number}_battery_voltage",
|
|
81
|
+
name=f"{self.model} {self.serial_number} Battery Voltage",
|
|
82
|
+
device_class=DeviceClass.VOLTAGE,
|
|
83
|
+
state_class=StateClass.MEASUREMENT,
|
|
84
|
+
unit_of_measurement="V",
|
|
85
|
+
value=self._runtime.vBat / 100.0, # Scaled value
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# PV Power
|
|
90
|
+
if hasattr(self._runtime, "ppv"):
|
|
91
|
+
entities.append(
|
|
92
|
+
Entity(
|
|
93
|
+
unique_id=f"{self.serial_number}_pv_power",
|
|
94
|
+
name=f"{self.model} {self.serial_number} PV Power",
|
|
95
|
+
device_class=DeviceClass.POWER,
|
|
96
|
+
state_class=StateClass.MEASUREMENT,
|
|
97
|
+
unit_of_measurement="W",
|
|
98
|
+
value=self._runtime.ppv,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Grid Power
|
|
103
|
+
if hasattr(self._runtime, "pToGrid"):
|
|
104
|
+
entities.append(
|
|
105
|
+
Entity(
|
|
106
|
+
unique_id=f"{self.serial_number}_grid_power",
|
|
107
|
+
name=f"{self.model} {self.serial_number} Grid Power",
|
|
108
|
+
device_class=DeviceClass.POWER,
|
|
109
|
+
state_class=StateClass.MEASUREMENT,
|
|
110
|
+
unit_of_measurement="W",
|
|
111
|
+
value=self._runtime.pToGrid,
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Load Power
|
|
116
|
+
if hasattr(self._runtime, "pToUser"):
|
|
117
|
+
entities.append(
|
|
118
|
+
Entity(
|
|
119
|
+
unique_id=f"{self.serial_number}_load_power",
|
|
120
|
+
name=f"{self.model} {self.serial_number} Load Power",
|
|
121
|
+
device_class=DeviceClass.POWER,
|
|
122
|
+
state_class=StateClass.MEASUREMENT,
|
|
123
|
+
unit_of_measurement="W",
|
|
124
|
+
value=self._runtime.pToUser,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Battery Charge/Discharge Power
|
|
129
|
+
if hasattr(self._runtime, "batPower"):
|
|
130
|
+
entities.append(
|
|
131
|
+
Entity(
|
|
132
|
+
unique_id=f"{self.serial_number}_battery_power",
|
|
133
|
+
name=f"{self.model} {self.serial_number} Battery Power",
|
|
134
|
+
device_class=DeviceClass.POWER,
|
|
135
|
+
state_class=StateClass.MEASUREMENT,
|
|
136
|
+
unit_of_measurement="W",
|
|
137
|
+
value=self._runtime.batPower,
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Temperature sensors
|
|
142
|
+
if hasattr(self._runtime, "tinner"):
|
|
143
|
+
entities.append(
|
|
144
|
+
Entity(
|
|
145
|
+
unique_id=f"{self.serial_number}_temp_internal",
|
|
146
|
+
name=f"{self.model} {self.serial_number} Internal Temperature",
|
|
147
|
+
device_class=DeviceClass.TEMPERATURE,
|
|
148
|
+
state_class=StateClass.MEASUREMENT,
|
|
149
|
+
unit_of_measurement="°C",
|
|
150
|
+
value=self._runtime.tinner,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if hasattr(self._runtime, "tBat"):
|
|
155
|
+
entities.append(
|
|
156
|
+
Entity(
|
|
157
|
+
unique_id=f"{self.serial_number}_temp_battery",
|
|
158
|
+
name=f"{self.model} {self.serial_number} Battery Temperature",
|
|
159
|
+
device_class=DeviceClass.TEMPERATURE,
|
|
160
|
+
state_class=StateClass.MEASUREMENT,
|
|
161
|
+
unit_of_measurement="°C",
|
|
162
|
+
value=self._runtime.tBat,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Energy sensors
|
|
167
|
+
if self._energy:
|
|
168
|
+
# Today's Production
|
|
169
|
+
entities.append(
|
|
170
|
+
Entity(
|
|
171
|
+
unique_id=f"{self.serial_number}_energy_today",
|
|
172
|
+
name=f"{self.model} {self.serial_number} Energy Today",
|
|
173
|
+
device_class=DeviceClass.ENERGY,
|
|
174
|
+
state_class=StateClass.TOTAL_INCREASING,
|
|
175
|
+
unit_of_measurement="kWh",
|
|
176
|
+
value=self.total_energy_today,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Lifetime Production
|
|
181
|
+
entities.append(
|
|
182
|
+
Entity(
|
|
183
|
+
unique_id=f"{self.serial_number}_energy_total",
|
|
184
|
+
name=f"{self.model} {self.serial_number} Energy Total",
|
|
185
|
+
device_class=DeviceClass.ENERGY,
|
|
186
|
+
state_class=StateClass.TOTAL_INCREASING,
|
|
187
|
+
unit_of_measurement="kWh",
|
|
188
|
+
value=self.total_energy_lifetime,
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return entities
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Hybrid inverter implementation for grid-tied models with battery storage.
|
|
2
|
+
|
|
3
|
+
This module provides the HybridInverter class for hybrid inverters that support:
|
|
4
|
+
- AC charging from grid
|
|
5
|
+
- Forced charge/discharge
|
|
6
|
+
- EPS (backup) mode
|
|
7
|
+
- Time-of-use scheduling
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from .generic import GenericInverter
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HybridInverter(GenericInverter):
|
|
21
|
+
"""Hybrid inverter with grid-tied and battery backup capabilities.
|
|
22
|
+
|
|
23
|
+
Extends GenericInverter with hybrid-specific controls:
|
|
24
|
+
- AC charging from grid
|
|
25
|
+
- Forced charge/discharge
|
|
26
|
+
- EPS/backup mode enable/disable
|
|
27
|
+
- Time-based charge/discharge scheduling
|
|
28
|
+
|
|
29
|
+
Suitable for models: FlexBOSS21, FlexBOSS18, 18KPV, 12KPV
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
```python
|
|
33
|
+
inverter = HybridInverter(
|
|
34
|
+
client=client,
|
|
35
|
+
serial_number="1234567890",
|
|
36
|
+
model="18KPV"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Enable AC charging at 50% power up to 100% SOC
|
|
40
|
+
await inverter.set_ac_charge(enabled=True, power_percent=50, soc_limit=100)
|
|
41
|
+
|
|
42
|
+
# Enable EPS backup mode
|
|
43
|
+
await inverter.set_eps_enabled(True)
|
|
44
|
+
|
|
45
|
+
# Set forced charge
|
|
46
|
+
await inverter.set_forced_charge(True)
|
|
47
|
+
```
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
# ============================================================================
|
|
51
|
+
# Hybrid-Specific Control Operations
|
|
52
|
+
# ============================================================================
|
|
53
|
+
|
|
54
|
+
async def get_ac_charge_settings(self) -> dict[str, int | bool]:
|
|
55
|
+
"""Get AC charge configuration.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Dictionary with:
|
|
59
|
+
- enabled: AC charge function enabled
|
|
60
|
+
- power_percent: Charge power (0-100%)
|
|
61
|
+
- soc_limit: Target SOC (0-100%)
|
|
62
|
+
- schedule1_enabled: Time schedule 1 enabled
|
|
63
|
+
- schedule2_enabled: Time schedule 2 enabled
|
|
64
|
+
|
|
65
|
+
Example:
|
|
66
|
+
>>> settings = await inverter.get_ac_charge_settings()
|
|
67
|
+
>>> settings
|
|
68
|
+
{
|
|
69
|
+
'enabled': True,
|
|
70
|
+
'power_percent': 50,
|
|
71
|
+
'soc_limit': 100,
|
|
72
|
+
'schedule1_enabled': True,
|
|
73
|
+
'schedule2_enabled': False
|
|
74
|
+
}
|
|
75
|
+
"""
|
|
76
|
+
from pylxpweb.constants import (
|
|
77
|
+
FUNC_EN_BIT_AC_CHARGE_EN,
|
|
78
|
+
FUNC_EN_REGISTER,
|
|
79
|
+
HOLD_AC_CHARGE_POWER_CMD,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Read function enable register for AC charge bit
|
|
83
|
+
func_params = await self.read_parameters(FUNC_EN_REGISTER, 1)
|
|
84
|
+
func_value = func_params.get(f"reg_{FUNC_EN_REGISTER}", 0)
|
|
85
|
+
ac_charge_enabled = bool(func_value & (1 << FUNC_EN_BIT_AC_CHARGE_EN))
|
|
86
|
+
|
|
87
|
+
# Read AC charge parameters
|
|
88
|
+
ac_params = await self.read_parameters(HOLD_AC_CHARGE_POWER_CMD, 8)
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
"enabled": ac_charge_enabled,
|
|
92
|
+
"power_percent": ac_params.get("HOLD_AC_CHARGE_POWER_CMD", 0),
|
|
93
|
+
"soc_limit": ac_params.get("HOLD_AC_CHARGE_SOC_LIMIT"),
|
|
94
|
+
"schedule1_enabled": bool(ac_params.get("HOLD_AC_CHARGE_ENABLE_1", 0)),
|
|
95
|
+
"schedule2_enabled": bool(ac_params.get("HOLD_AC_CHARGE_ENABLE_2", 0)),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async def set_ac_charge(
|
|
99
|
+
self, enabled: bool, power_percent: int | None = None, soc_limit: int | None = None
|
|
100
|
+
) -> bool:
|
|
101
|
+
"""Configure AC charging from grid.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
enabled: Enable AC charging
|
|
105
|
+
power_percent: Charge power percentage (0-100), optional
|
|
106
|
+
soc_limit: Target SOC percentage (0-100), optional
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
True if successful
|
|
110
|
+
|
|
111
|
+
Example:
|
|
112
|
+
>>> # Enable AC charge at 50% power to 100% SOC
|
|
113
|
+
>>> await inverter.set_ac_charge(True, power_percent=50, soc_limit=100)
|
|
114
|
+
True
|
|
115
|
+
|
|
116
|
+
>>> # Disable AC charge
|
|
117
|
+
>>> await inverter.set_ac_charge(False)
|
|
118
|
+
True
|
|
119
|
+
"""
|
|
120
|
+
from pylxpweb.constants import (
|
|
121
|
+
FUNC_EN_BIT_AC_CHARGE_EN,
|
|
122
|
+
FUNC_EN_REGISTER,
|
|
123
|
+
HOLD_AC_CHARGE_POWER_CMD,
|
|
124
|
+
HOLD_AC_CHARGE_SOC_LIMIT,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Validate parameters first (before any API calls)
|
|
128
|
+
if power_percent is not None and not 0 <= power_percent <= 100:
|
|
129
|
+
raise ValueError("power_percent must be between 0 and 100")
|
|
130
|
+
|
|
131
|
+
if soc_limit is not None and not 0 <= soc_limit <= 100:
|
|
132
|
+
raise ValueError("soc_limit must be between 0 and 100")
|
|
133
|
+
|
|
134
|
+
# Update function enable bit
|
|
135
|
+
func_params = await self.read_parameters(FUNC_EN_REGISTER, 1)
|
|
136
|
+
current_func = func_params.get(f"reg_{FUNC_EN_REGISTER}", 0)
|
|
137
|
+
|
|
138
|
+
if enabled:
|
|
139
|
+
new_func = current_func | (1 << FUNC_EN_BIT_AC_CHARGE_EN)
|
|
140
|
+
else:
|
|
141
|
+
new_func = current_func & ~(1 << FUNC_EN_BIT_AC_CHARGE_EN)
|
|
142
|
+
|
|
143
|
+
params_to_write = {FUNC_EN_REGISTER: new_func}
|
|
144
|
+
|
|
145
|
+
# Add power and SOC limit if provided (already validated)
|
|
146
|
+
if power_percent is not None:
|
|
147
|
+
params_to_write[HOLD_AC_CHARGE_POWER_CMD] = power_percent
|
|
148
|
+
|
|
149
|
+
if soc_limit is not None:
|
|
150
|
+
params_to_write[HOLD_AC_CHARGE_SOC_LIMIT] = soc_limit
|
|
151
|
+
|
|
152
|
+
return await self.write_parameters(params_to_write)
|
|
153
|
+
|
|
154
|
+
async def set_eps_enabled(self, enabled: bool) -> bool:
|
|
155
|
+
"""Enable or disable EPS (backup/off-grid) mode.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
enabled: True to enable EPS mode, False to disable
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
True if successful
|
|
162
|
+
|
|
163
|
+
Example:
|
|
164
|
+
>>> await inverter.set_eps_enabled(True)
|
|
165
|
+
True
|
|
166
|
+
"""
|
|
167
|
+
from pylxpweb.constants import FUNC_EN_BIT_EPS_EN, FUNC_EN_REGISTER
|
|
168
|
+
|
|
169
|
+
# Read current function enable register
|
|
170
|
+
params = await self.read_parameters(FUNC_EN_REGISTER, 1)
|
|
171
|
+
current_value = params.get(f"reg_{FUNC_EN_REGISTER}", 0)
|
|
172
|
+
|
|
173
|
+
if enabled:
|
|
174
|
+
new_value = current_value | (1 << FUNC_EN_BIT_EPS_EN)
|
|
175
|
+
else:
|
|
176
|
+
new_value = current_value & ~(1 << FUNC_EN_BIT_EPS_EN)
|
|
177
|
+
|
|
178
|
+
return await self.write_parameters({FUNC_EN_REGISTER: new_value})
|
|
179
|
+
|
|
180
|
+
async def set_forced_charge(self, enabled: bool) -> bool:
|
|
181
|
+
"""Enable or disable forced charge mode.
|
|
182
|
+
|
|
183
|
+
Forces inverter to charge batteries regardless of time schedule.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
enabled: True to enable forced charge, False to disable
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
True if successful
|
|
190
|
+
|
|
191
|
+
Example:
|
|
192
|
+
>>> await inverter.set_forced_charge(True)
|
|
193
|
+
True
|
|
194
|
+
"""
|
|
195
|
+
from pylxpweb.constants import FUNC_EN_BIT_FORCED_CHG_EN, FUNC_EN_REGISTER
|
|
196
|
+
|
|
197
|
+
params = await self.read_parameters(FUNC_EN_REGISTER, 1)
|
|
198
|
+
current_value = params.get(f"reg_{FUNC_EN_REGISTER}", 0)
|
|
199
|
+
|
|
200
|
+
if enabled:
|
|
201
|
+
new_value = current_value | (1 << FUNC_EN_BIT_FORCED_CHG_EN)
|
|
202
|
+
else:
|
|
203
|
+
new_value = current_value & ~(1 << FUNC_EN_BIT_FORCED_CHG_EN)
|
|
204
|
+
|
|
205
|
+
return await self.write_parameters({FUNC_EN_REGISTER: new_value})
|
|
206
|
+
|
|
207
|
+
async def set_forced_discharge(self, enabled: bool) -> bool:
|
|
208
|
+
"""Enable or disable forced discharge mode.
|
|
209
|
+
|
|
210
|
+
Forces inverter to discharge batteries regardless of time schedule.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
enabled: True to enable forced discharge, False to disable
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
True if successful
|
|
217
|
+
|
|
218
|
+
Example:
|
|
219
|
+
>>> await inverter.set_forced_discharge(True)
|
|
220
|
+
True
|
|
221
|
+
"""
|
|
222
|
+
from pylxpweb.constants import FUNC_EN_BIT_FORCED_DISCHG_EN, FUNC_EN_REGISTER
|
|
223
|
+
|
|
224
|
+
params = await self.read_parameters(FUNC_EN_REGISTER, 1)
|
|
225
|
+
current_value = params.get(f"reg_{FUNC_EN_REGISTER}", 0)
|
|
226
|
+
|
|
227
|
+
if enabled:
|
|
228
|
+
new_value = current_value | (1 << FUNC_EN_BIT_FORCED_DISCHG_EN)
|
|
229
|
+
else:
|
|
230
|
+
new_value = current_value & ~(1 << FUNC_EN_BIT_FORCED_DISCHG_EN)
|
|
231
|
+
|
|
232
|
+
return await self.write_parameters({FUNC_EN_REGISTER: new_value})
|
|
233
|
+
|
|
234
|
+
async def get_charge_discharge_power(self) -> dict[str, int]:
|
|
235
|
+
"""Get charge and discharge power settings.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Dictionary with:
|
|
239
|
+
- charge_power_percent: AC charge power (0-100%)
|
|
240
|
+
- discharge_power_percent: Discharge power (0-100%)
|
|
241
|
+
|
|
242
|
+
Example:
|
|
243
|
+
>>> settings = await inverter.get_charge_discharge_power()
|
|
244
|
+
>>> settings
|
|
245
|
+
{'charge_power_percent': 50, 'discharge_power_percent': 100}
|
|
246
|
+
"""
|
|
247
|
+
from pylxpweb.constants import HOLD_AC_CHARGE_POWER_CMD
|
|
248
|
+
|
|
249
|
+
params = await self.read_parameters(HOLD_AC_CHARGE_POWER_CMD, 9)
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
"charge_power_percent": params.get("HOLD_AC_CHARGE_POWER_CMD", 0),
|
|
253
|
+
"discharge_power_percent": params.get("HOLD_DISCHG_POWER_CMD"),
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async def set_discharge_power(self, power_percent: int) -> bool:
|
|
257
|
+
"""Set battery discharge power limit.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
power_percent: Discharge power percentage (0-100)
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
True if successful
|
|
264
|
+
|
|
265
|
+
Example:
|
|
266
|
+
>>> await inverter.set_discharge_power(80)
|
|
267
|
+
True
|
|
268
|
+
"""
|
|
269
|
+
from pylxpweb.constants import HOLD_DISCHG_POWER_CMD
|
|
270
|
+
|
|
271
|
+
if not 0 <= power_percent <= 100:
|
|
272
|
+
raise ValueError("power_percent must be between 0 and 100")
|
|
273
|
+
|
|
274
|
+
return await self.write_parameters({HOLD_DISCHG_POWER_CMD: power_percent})
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""MID Device (GridBOSS) module for grid management and load control.
|
|
2
|
+
|
|
3
|
+
This module provides the MIDDevice class for GridBOSS devices that handle
|
|
4
|
+
grid interconnection, UPS functionality, smart loads, and AC coupling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from pylxpweb.exceptions import LuxpowerAPIError, LuxpowerConnectionError, LuxpowerDeviceError
|
|
14
|
+
|
|
15
|
+
from ._firmware_update_mixin import FirmwareUpdateMixin
|
|
16
|
+
from ._mid_runtime_properties import MIDRuntimePropertiesMixin
|
|
17
|
+
from .base import BaseDevice
|
|
18
|
+
from .models import DeviceClass, DeviceInfo, Entity, StateClass
|
|
19
|
+
|
|
20
|
+
_LOGGER = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from pylxpweb import LuxpowerClient
|
|
24
|
+
from pylxpweb.models import MidboxRuntime
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MIDDevice(FirmwareUpdateMixin, MIDRuntimePropertiesMixin, BaseDevice):
|
|
28
|
+
"""Represents a GridBOSS/MID device for grid management.
|
|
29
|
+
|
|
30
|
+
GridBOSS devices handle:
|
|
31
|
+
- Grid interconnection and UPS functionality
|
|
32
|
+
- Smart load management (4 configurable outputs)
|
|
33
|
+
- AC coupling for additional inverters/generators
|
|
34
|
+
- Load monitoring and control
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
```python
|
|
38
|
+
# MIDDevice is typically created from parallel group data
|
|
39
|
+
mid_device = MIDDevice(
|
|
40
|
+
client=client,
|
|
41
|
+
serial_number="1234567890",
|
|
42
|
+
model="GridBOSS"
|
|
43
|
+
)
|
|
44
|
+
await mid_device.refresh()
|
|
45
|
+
print(f"Grid Power: {mid_device.grid_power}W")
|
|
46
|
+
print(f"UPS Power: {mid_device.ups_power}W")
|
|
47
|
+
```
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
client: LuxpowerClient,
|
|
53
|
+
serial_number: str,
|
|
54
|
+
model: str = "GridBOSS",
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Initialize MID device.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
client: LuxpowerClient instance for API access
|
|
60
|
+
serial_number: MID device serial number (10-digit)
|
|
61
|
+
model: Device model name (default: "GridBOSS")
|
|
62
|
+
"""
|
|
63
|
+
super().__init__(client, serial_number, model)
|
|
64
|
+
|
|
65
|
+
# Runtime data (private - use properties for access)
|
|
66
|
+
self._runtime: MidboxRuntime | None = None
|
|
67
|
+
|
|
68
|
+
# Initialize firmware update detection (from FirmwareUpdateMixin)
|
|
69
|
+
self._init_firmware_update_cache()
|
|
70
|
+
|
|
71
|
+
async def refresh(self) -> None:
|
|
72
|
+
"""Refresh MID device runtime data from API."""
|
|
73
|
+
try:
|
|
74
|
+
runtime_data = await self._client.api.devices.get_midbox_runtime(self.serial_number)
|
|
75
|
+
self._runtime = runtime_data
|
|
76
|
+
self._last_refresh = datetime.now()
|
|
77
|
+
except (LuxpowerAPIError, LuxpowerConnectionError, LuxpowerDeviceError) as err:
|
|
78
|
+
# Graceful error handling - keep existing cached data
|
|
79
|
+
_LOGGER.debug("Failed to fetch MID device runtime for %s: %s", self.serial_number, err)
|
|
80
|
+
|
|
81
|
+
# All properties are provided by MIDRuntimePropertiesMixin
|
|
82
|
+
|
|
83
|
+
def to_device_info(self) -> DeviceInfo:
|
|
84
|
+
"""Convert to device info model.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
DeviceInfo with MID device metadata.
|
|
88
|
+
"""
|
|
89
|
+
return DeviceInfo(
|
|
90
|
+
identifiers={("pylxpweb", f"mid_{self.serial_number}")},
|
|
91
|
+
name=f"GridBOSS {self.serial_number}",
|
|
92
|
+
manufacturer="EG4/Luxpower",
|
|
93
|
+
model=self.model,
|
|
94
|
+
sw_version=self.firmware_version,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def to_entities(self) -> list[Entity]:
|
|
98
|
+
"""Generate entities for this MID device.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
List of Entity objects for GridBOSS monitoring.
|
|
102
|
+
|
|
103
|
+
Note: This implementation focuses on core grid/UPS monitoring.
|
|
104
|
+
Future versions will add smart loads, AC coupling, and generator sensors.
|
|
105
|
+
"""
|
|
106
|
+
if self._runtime is None:
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
entities = []
|
|
110
|
+
|
|
111
|
+
# Grid Voltage
|
|
112
|
+
entities.append(
|
|
113
|
+
Entity(
|
|
114
|
+
unique_id=f"{self.serial_number}_grid_voltage",
|
|
115
|
+
name=f"{self.model} {self.serial_number} Grid Voltage",
|
|
116
|
+
device_class=DeviceClass.VOLTAGE,
|
|
117
|
+
state_class=StateClass.MEASUREMENT,
|
|
118
|
+
unit_of_measurement="V",
|
|
119
|
+
value=self.grid_voltage,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Grid Power
|
|
124
|
+
entities.append(
|
|
125
|
+
Entity(
|
|
126
|
+
unique_id=f"{self.serial_number}_grid_power",
|
|
127
|
+
name=f"{self.model} {self.serial_number} Grid Power",
|
|
128
|
+
device_class=DeviceClass.POWER,
|
|
129
|
+
state_class=StateClass.MEASUREMENT,
|
|
130
|
+
unit_of_measurement="W",
|
|
131
|
+
value=self.grid_power,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# UPS Voltage
|
|
136
|
+
entities.append(
|
|
137
|
+
Entity(
|
|
138
|
+
unique_id=f"{self.serial_number}_ups_voltage",
|
|
139
|
+
name=f"{self.model} {self.serial_number} UPS Voltage",
|
|
140
|
+
device_class=DeviceClass.VOLTAGE,
|
|
141
|
+
state_class=StateClass.MEASUREMENT,
|
|
142
|
+
unit_of_measurement="V",
|
|
143
|
+
value=self.ups_voltage,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# UPS Power
|
|
148
|
+
entities.append(
|
|
149
|
+
Entity(
|
|
150
|
+
unique_id=f"{self.serial_number}_ups_power",
|
|
151
|
+
name=f"{self.model} {self.serial_number} UPS Power",
|
|
152
|
+
device_class=DeviceClass.POWER,
|
|
153
|
+
state_class=StateClass.MEASUREMENT,
|
|
154
|
+
unit_of_measurement="W",
|
|
155
|
+
value=self.ups_power,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Hybrid Power
|
|
160
|
+
entities.append(
|
|
161
|
+
Entity(
|
|
162
|
+
unique_id=f"{self.serial_number}_hybrid_power",
|
|
163
|
+
name=f"{self.model} {self.serial_number} Hybrid Power",
|
|
164
|
+
device_class=DeviceClass.POWER,
|
|
165
|
+
state_class=StateClass.MEASUREMENT,
|
|
166
|
+
unit_of_measurement="W",
|
|
167
|
+
value=self.hybrid_power,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Grid Frequency
|
|
172
|
+
entities.append(
|
|
173
|
+
Entity(
|
|
174
|
+
unique_id=f"{self.serial_number}_grid_frequency",
|
|
175
|
+
name=f"{self.model} {self.serial_number} Grid Frequency",
|
|
176
|
+
device_class=DeviceClass.FREQUENCY,
|
|
177
|
+
state_class=StateClass.MEASUREMENT,
|
|
178
|
+
unit_of_measurement="Hz",
|
|
179
|
+
value=self.grid_frequency,
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return entities
|