Homevolt 0.2.3__py3-none-any.whl → 0.3.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.
- homevolt/__init__.py +1 -4
- homevolt/homevolt.py +744 -31
- homevolt/models.py +1 -21
- {homevolt-0.2.3.dist-info → homevolt-0.3.0.dist-info}/METADATA +26 -38
- homevolt-0.3.0.dist-info/RECORD +10 -0
- {homevolt-0.2.3.dist-info → homevolt-0.3.0.dist-info}/WHEEL +1 -1
- homevolt-0.3.0.dist-info/licenses/LICENSE +674 -0
- homevolt/device.py +0 -760
- homevolt-0.2.3.dist-info/RECORD +0 -10
- {homevolt-0.2.3.dist-info → homevolt-0.3.0.dist-info}/top_level.txt +0 -0
homevolt/device.py
DELETED
|
@@ -1,760 +0,0 @@
|
|
|
1
|
-
"""Device class for Homevolt EMS devices."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import logging
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
import aiohttp
|
|
9
|
-
|
|
10
|
-
from .const import (
|
|
11
|
-
DEVICE_MAP,
|
|
12
|
-
ENDPOINT_CONSOLE,
|
|
13
|
-
ENDPOINT_EMS,
|
|
14
|
-
ENDPOINT_PARAMS,
|
|
15
|
-
ENDPOINT_SCHEDULE,
|
|
16
|
-
SCHEDULE_TYPE,
|
|
17
|
-
)
|
|
18
|
-
from .exceptions import (
|
|
19
|
-
HomevoltAuthenticationError,
|
|
20
|
-
HomevoltConnectionError,
|
|
21
|
-
HomevoltDataError,
|
|
22
|
-
)
|
|
23
|
-
from .models import DeviceMetadata, Sensor, SensorType
|
|
24
|
-
|
|
25
|
-
_LOGGER = logging.getLogger(__name__)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class Device:
|
|
29
|
-
"""Represents a Homevolt EMS device."""
|
|
30
|
-
|
|
31
|
-
def __init__(
|
|
32
|
-
self,
|
|
33
|
-
hostname: str,
|
|
34
|
-
password: str | None,
|
|
35
|
-
websession: aiohttp.ClientSession,
|
|
36
|
-
) -> None:
|
|
37
|
-
"""Initialize the device.
|
|
38
|
-
|
|
39
|
-
Args:
|
|
40
|
-
hostname: Hostname of the Homevolt device
|
|
41
|
-
password: Optional password for authentication
|
|
42
|
-
websession: aiohttp ClientSession for making requests
|
|
43
|
-
"""
|
|
44
|
-
self.hostname = hostname
|
|
45
|
-
self._password = password
|
|
46
|
-
self._websession = websession
|
|
47
|
-
self._auth = aiohttp.BasicAuth("admin", password) if password else None
|
|
48
|
-
|
|
49
|
-
self.device_id: str | None = None
|
|
50
|
-
self.sensors: dict[str, Sensor] = {}
|
|
51
|
-
self.device_metadata: dict[str, DeviceMetadata] = {}
|
|
52
|
-
self.current_schedule: dict[str, Any] | None = None
|
|
53
|
-
|
|
54
|
-
async def update_info(self) -> None:
|
|
55
|
-
"""Fetch and update all device information."""
|
|
56
|
-
await self.fetch_ems_data()
|
|
57
|
-
await self.fetch_schedule_data()
|
|
58
|
-
|
|
59
|
-
async def fetch_ems_data(self) -> None:
|
|
60
|
-
"""Fetch EMS data from the device."""
|
|
61
|
-
try:
|
|
62
|
-
url = f"{self.hostname}{ENDPOINT_EMS}"
|
|
63
|
-
async with self._websession.get(url, auth=self._auth) as response:
|
|
64
|
-
if response.status == 401:
|
|
65
|
-
raise HomevoltAuthenticationError("Authentication failed")
|
|
66
|
-
response.raise_for_status()
|
|
67
|
-
ems_data = await response.json()
|
|
68
|
-
except aiohttp.ClientError as err:
|
|
69
|
-
raise HomevoltConnectionError(f"Failed to connect to device: {err}") from err
|
|
70
|
-
except Exception as err:
|
|
71
|
-
raise HomevoltDataError(f"Failed to parse EMS data: {err}") from err
|
|
72
|
-
|
|
73
|
-
self._parse_ems_data(ems_data)
|
|
74
|
-
|
|
75
|
-
async def fetch_schedule_data(self) -> None:
|
|
76
|
-
"""Fetch schedule data from the device."""
|
|
77
|
-
try:
|
|
78
|
-
url = f"{self.hostname}{ENDPOINT_SCHEDULE}"
|
|
79
|
-
async with self._websession.get(url, auth=self._auth) as response:
|
|
80
|
-
if response.status == 401:
|
|
81
|
-
raise HomevoltAuthenticationError("Authentication failed")
|
|
82
|
-
response.raise_for_status()
|
|
83
|
-
schedule_data = await response.json()
|
|
84
|
-
except aiohttp.ClientError as err:
|
|
85
|
-
raise HomevoltConnectionError(f"Failed to connect to device: {err}") from err
|
|
86
|
-
except Exception as err:
|
|
87
|
-
raise HomevoltDataError(f"Failed to parse schedule data: {err}") from err
|
|
88
|
-
|
|
89
|
-
self._parse_schedule_data(schedule_data)
|
|
90
|
-
|
|
91
|
-
def _parse_ems_data(self, ems_data: dict[str, Any]) -> None:
|
|
92
|
-
"""Parse EMS JSON response."""
|
|
93
|
-
if not ems_data.get("ems") or not ems_data["ems"]:
|
|
94
|
-
raise HomevoltDataError("No EMS data found in response")
|
|
95
|
-
|
|
96
|
-
device_id = str(ems_data["ems"][0]["ecu_id"])
|
|
97
|
-
self.device_id = device_id
|
|
98
|
-
ems_device_id = f"ems_{device_id}"
|
|
99
|
-
|
|
100
|
-
# Initialize device metadata
|
|
101
|
-
self.device_metadata = {
|
|
102
|
-
ems_device_id: DeviceMetadata(name=f"Homevolt EMS {device_id}", model="Homevolt EMS"),
|
|
103
|
-
"grid": DeviceMetadata(name="Homevolt Grid Sensor", model="Grid Sensor"),
|
|
104
|
-
"solar": DeviceMetadata(name="Homevolt Solar Sensor", model="Solar Sensor"),
|
|
105
|
-
"load": DeviceMetadata(name="Homevolt Load Sensor", model="Load Sensor"),
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
# Initialize sensors dictionary
|
|
109
|
-
self.sensors = {}
|
|
110
|
-
|
|
111
|
-
# EMS device sensors - all main EMS data
|
|
112
|
-
ems = ems_data["ems"][0]
|
|
113
|
-
self.sensors.update(
|
|
114
|
-
{
|
|
115
|
-
"L1 Voltage": Sensor(
|
|
116
|
-
value=ems["ems_voltage"]["l1"] / 10,
|
|
117
|
-
type=SensorType.VOLTAGE,
|
|
118
|
-
device_identifier=ems_device_id,
|
|
119
|
-
),
|
|
120
|
-
"L2 Voltage": Sensor(
|
|
121
|
-
value=ems["ems_voltage"]["l2"] / 10,
|
|
122
|
-
type=SensorType.VOLTAGE,
|
|
123
|
-
device_identifier=ems_device_id,
|
|
124
|
-
),
|
|
125
|
-
"L3 Voltage": Sensor(
|
|
126
|
-
value=ems["ems_voltage"]["l3"] / 10,
|
|
127
|
-
type=SensorType.VOLTAGE,
|
|
128
|
-
device_identifier=ems_device_id,
|
|
129
|
-
),
|
|
130
|
-
"L1_L2 Voltage": Sensor(
|
|
131
|
-
value=ems["ems_voltage"]["l1_l2"] / 10,
|
|
132
|
-
type=SensorType.VOLTAGE,
|
|
133
|
-
device_identifier=ems_device_id,
|
|
134
|
-
),
|
|
135
|
-
"L2_L3 Voltage": Sensor(
|
|
136
|
-
value=ems["ems_voltage"]["l2_l3"] / 10,
|
|
137
|
-
type=SensorType.VOLTAGE,
|
|
138
|
-
device_identifier=ems_device_id,
|
|
139
|
-
),
|
|
140
|
-
"L3_L1 Voltage": Sensor(
|
|
141
|
-
value=ems["ems_voltage"]["l3_l1"] / 10,
|
|
142
|
-
type=SensorType.VOLTAGE,
|
|
143
|
-
device_identifier=ems_device_id,
|
|
144
|
-
),
|
|
145
|
-
"L1 Current": Sensor(
|
|
146
|
-
value=ems["ems_current"]["l1"],
|
|
147
|
-
type=SensorType.CURRENT,
|
|
148
|
-
device_identifier=ems_device_id,
|
|
149
|
-
),
|
|
150
|
-
"L2 Current": Sensor(
|
|
151
|
-
value=ems["ems_current"]["l2"],
|
|
152
|
-
type=SensorType.CURRENT,
|
|
153
|
-
device_identifier=ems_device_id,
|
|
154
|
-
),
|
|
155
|
-
"L3 Current": Sensor(
|
|
156
|
-
value=ems["ems_current"]["l3"],
|
|
157
|
-
type=SensorType.CURRENT,
|
|
158
|
-
device_identifier=ems_device_id,
|
|
159
|
-
),
|
|
160
|
-
"System Temperature": Sensor(
|
|
161
|
-
value=ems["ems_data"]["sys_temp"] / 10.0,
|
|
162
|
-
type=SensorType.TEMPERATURE,
|
|
163
|
-
device_identifier=ems_device_id,
|
|
164
|
-
),
|
|
165
|
-
"Imported Energy": Sensor(
|
|
166
|
-
value=ems["ems_aggregate"]["imported_kwh"],
|
|
167
|
-
type=SensorType.ENERGY_INCREASING,
|
|
168
|
-
device_identifier=ems_device_id,
|
|
169
|
-
),
|
|
170
|
-
"Exported Energy": Sensor(
|
|
171
|
-
value=ems["ems_aggregate"]["exported_kwh"],
|
|
172
|
-
type=SensorType.ENERGY_INCREASING,
|
|
173
|
-
device_identifier=ems_device_id,
|
|
174
|
-
),
|
|
175
|
-
"Available Charging Power": Sensor(
|
|
176
|
-
value=ems["ems_prediction"]["avail_ch_pwr"],
|
|
177
|
-
type=SensorType.POWER,
|
|
178
|
-
device_identifier=ems_device_id,
|
|
179
|
-
),
|
|
180
|
-
"Available Discharge Power": Sensor(
|
|
181
|
-
value=ems["ems_prediction"]["avail_di_pwr"],
|
|
182
|
-
type=SensorType.POWER,
|
|
183
|
-
device_identifier=ems_device_id,
|
|
184
|
-
),
|
|
185
|
-
"Available Charging Energy": Sensor(
|
|
186
|
-
value=ems["ems_prediction"]["avail_ch_energy"],
|
|
187
|
-
type=SensorType.ENERGY_TOTAL,
|
|
188
|
-
device_identifier=ems_device_id,
|
|
189
|
-
),
|
|
190
|
-
"Available Discharge Energy": Sensor(
|
|
191
|
-
value=ems["ems_prediction"]["avail_di_energy"],
|
|
192
|
-
type=SensorType.ENERGY_TOTAL,
|
|
193
|
-
device_identifier=ems_device_id,
|
|
194
|
-
),
|
|
195
|
-
"Power": Sensor(
|
|
196
|
-
value=ems["ems_data"]["power"],
|
|
197
|
-
type=SensorType.POWER,
|
|
198
|
-
device_identifier=ems_device_id,
|
|
199
|
-
),
|
|
200
|
-
"Frequency": Sensor(
|
|
201
|
-
value=ems["ems_data"]["frequency"],
|
|
202
|
-
type=SensorType.FREQUENCY,
|
|
203
|
-
device_identifier=ems_device_id,
|
|
204
|
-
),
|
|
205
|
-
"Battery State of Charge": Sensor(
|
|
206
|
-
value=ems["ems_data"]["soc_avg"] / 100,
|
|
207
|
-
type=SensorType.PERCENTAGE,
|
|
208
|
-
device_identifier=ems_device_id,
|
|
209
|
-
),
|
|
210
|
-
}
|
|
211
|
-
)
|
|
212
|
-
|
|
213
|
-
# Battery sensors
|
|
214
|
-
for bat_id, battery in enumerate(ems.get("bms_data", [])):
|
|
215
|
-
battery_device_id = f"battery_{bat_id}"
|
|
216
|
-
self.device_metadata[battery_device_id] = DeviceMetadata(
|
|
217
|
-
name=f"Homevolt Battery {bat_id}",
|
|
218
|
-
model="Homevolt Battery",
|
|
219
|
-
)
|
|
220
|
-
if "soc" in battery:
|
|
221
|
-
self.sensors[f"Homevolt battery {bat_id}"] = Sensor(
|
|
222
|
-
value=battery["soc"] / 100,
|
|
223
|
-
type=SensorType.PERCENTAGE,
|
|
224
|
-
device_identifier=battery_device_id,
|
|
225
|
-
)
|
|
226
|
-
if "tmin" in battery:
|
|
227
|
-
self.sensors[f"Homevolt battery {bat_id} tmin"] = Sensor(
|
|
228
|
-
value=battery["tmin"] / 10,
|
|
229
|
-
type=SensorType.TEMPERATURE,
|
|
230
|
-
device_identifier=battery_device_id,
|
|
231
|
-
)
|
|
232
|
-
if "tmax" in battery:
|
|
233
|
-
self.sensors[f"Homevolt battery {bat_id} tmax"] = Sensor(
|
|
234
|
-
value=battery["tmax"] / 10,
|
|
235
|
-
type=SensorType.TEMPERATURE,
|
|
236
|
-
device_identifier=battery_device_id,
|
|
237
|
-
)
|
|
238
|
-
if "cycle_count" in battery:
|
|
239
|
-
self.sensors[f"Homevolt battery {bat_id} charge cycles"] = Sensor(
|
|
240
|
-
value=battery["cycle_count"],
|
|
241
|
-
type=SensorType.COUNT,
|
|
242
|
-
device_identifier=battery_device_id,
|
|
243
|
-
)
|
|
244
|
-
if "voltage" in battery:
|
|
245
|
-
self.sensors[f"Homevolt battery {bat_id} voltage"] = Sensor(
|
|
246
|
-
value=battery["voltage"] / 100,
|
|
247
|
-
type=SensorType.VOLTAGE,
|
|
248
|
-
device_identifier=battery_device_id,
|
|
249
|
-
)
|
|
250
|
-
if "current" in battery:
|
|
251
|
-
self.sensors[f"Homevolt battery {bat_id} current"] = Sensor(
|
|
252
|
-
value=battery["current"],
|
|
253
|
-
type=SensorType.CURRENT,
|
|
254
|
-
device_identifier=battery_device_id,
|
|
255
|
-
)
|
|
256
|
-
if "power" in battery:
|
|
257
|
-
self.sensors[f"Homevolt battery {bat_id} power"] = Sensor(
|
|
258
|
-
value=battery["power"],
|
|
259
|
-
type=SensorType.POWER,
|
|
260
|
-
device_identifier=battery_device_id,
|
|
261
|
-
)
|
|
262
|
-
if "soh" in battery:
|
|
263
|
-
self.sensors[f"Homevolt battery {bat_id} soh"] = Sensor(
|
|
264
|
-
value=battery["soh"] / 100,
|
|
265
|
-
type=SensorType.PERCENTAGE,
|
|
266
|
-
device_identifier=battery_device_id,
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
# External sensors (grid, solar, load)
|
|
270
|
-
for sensor in ems_data.get("sensors", []):
|
|
271
|
-
if not sensor.get("available"):
|
|
272
|
-
continue
|
|
273
|
-
|
|
274
|
-
sensor_type = sensor["type"]
|
|
275
|
-
sensor_device_id = DEVICE_MAP.get(sensor_type)
|
|
276
|
-
|
|
277
|
-
if not sensor_device_id:
|
|
278
|
-
continue
|
|
279
|
-
|
|
280
|
-
# Calculate total power from all phases
|
|
281
|
-
total_power = sum(phase["power"] for phase in sensor.get("phase", []))
|
|
282
|
-
|
|
283
|
-
self.sensors[f"Power {sensor_type}"] = Sensor(
|
|
284
|
-
value=total_power,
|
|
285
|
-
type=SensorType.POWER,
|
|
286
|
-
device_identifier=sensor_device_id,
|
|
287
|
-
)
|
|
288
|
-
self.sensors[f"Energy imported {sensor_type}"] = Sensor(
|
|
289
|
-
value=sensor.get("energy_imported", 0),
|
|
290
|
-
type=SensorType.ENERGY_INCREASING,
|
|
291
|
-
device_identifier=sensor_device_id,
|
|
292
|
-
)
|
|
293
|
-
self.sensors[f"Energy exported {sensor_type}"] = Sensor(
|
|
294
|
-
value=sensor.get("energy_exported", 0),
|
|
295
|
-
type=SensorType.ENERGY_INCREASING,
|
|
296
|
-
device_identifier=sensor_device_id,
|
|
297
|
-
)
|
|
298
|
-
self.sensors[f"RSSI {sensor_type}"] = Sensor(
|
|
299
|
-
value=sensor.get("rssi"),
|
|
300
|
-
type=SensorType.SIGNAL_STRENGTH,
|
|
301
|
-
device_identifier=sensor_device_id,
|
|
302
|
-
)
|
|
303
|
-
self.sensors[f"Average RSSI {sensor_type}"] = Sensor(
|
|
304
|
-
value=sensor.get("average_rssi"),
|
|
305
|
-
type=SensorType.SIGNAL_STRENGTH,
|
|
306
|
-
device_identifier=sensor_device_id,
|
|
307
|
-
)
|
|
308
|
-
|
|
309
|
-
# Phase-specific sensors
|
|
310
|
-
for phase_name, phase in zip(["L1", "L2", "L3"], sensor.get("phase", []), strict=False):
|
|
311
|
-
self.sensors[f"{phase_name} Voltage {sensor_type}"] = Sensor(
|
|
312
|
-
value=phase.get("voltage"),
|
|
313
|
-
type=SensorType.VOLTAGE,
|
|
314
|
-
device_identifier=sensor_device_id,
|
|
315
|
-
)
|
|
316
|
-
self.sensors[f"{phase_name} Current {sensor_type}"] = Sensor(
|
|
317
|
-
value=phase.get("amp"),
|
|
318
|
-
type=SensorType.CURRENT,
|
|
319
|
-
device_identifier=sensor_device_id,
|
|
320
|
-
)
|
|
321
|
-
self.sensors[f"{phase_name} Power {sensor_type}"] = Sensor(
|
|
322
|
-
value=phase.get("power"),
|
|
323
|
-
type=SensorType.POWER,
|
|
324
|
-
device_identifier=sensor_device_id,
|
|
325
|
-
)
|
|
326
|
-
|
|
327
|
-
def _parse_schedule_data(self, schedule_data: dict[str, Any]) -> None:
|
|
328
|
-
"""Parse schedule JSON response."""
|
|
329
|
-
self.current_schedule = schedule_data
|
|
330
|
-
|
|
331
|
-
if not self.device_id:
|
|
332
|
-
return
|
|
333
|
-
|
|
334
|
-
ems_device_id = f"ems_{self.device_id}"
|
|
335
|
-
|
|
336
|
-
self.sensors["Schedule id"] = Sensor(
|
|
337
|
-
value=schedule_data.get("schedule_id"),
|
|
338
|
-
type=SensorType.TEXT,
|
|
339
|
-
device_identifier=ems_device_id,
|
|
340
|
-
)
|
|
341
|
-
|
|
342
|
-
schedule = (
|
|
343
|
-
schedule_data.get("schedule", [{}])[0]
|
|
344
|
-
if schedule_data.get("schedule")
|
|
345
|
-
else {"type": -1, "params": {}}
|
|
346
|
-
)
|
|
347
|
-
|
|
348
|
-
self.sensors["Schedule Type"] = Sensor(
|
|
349
|
-
value=SCHEDULE_TYPE.get(schedule.get("type", -1)),
|
|
350
|
-
type=SensorType.SCHEDULE_TYPE,
|
|
351
|
-
device_identifier=ems_device_id,
|
|
352
|
-
)
|
|
353
|
-
self.sensors["Schedule Power Setpoint"] = Sensor(
|
|
354
|
-
value=schedule.get("params", {}).get("setpoint"),
|
|
355
|
-
type=SensorType.POWER,
|
|
356
|
-
device_identifier=ems_device_id,
|
|
357
|
-
)
|
|
358
|
-
self.sensors["Schedule Max Power"] = Sensor(
|
|
359
|
-
value=schedule.get("max_charge"),
|
|
360
|
-
type=SensorType.POWER,
|
|
361
|
-
device_identifier=ems_device_id,
|
|
362
|
-
)
|
|
363
|
-
self.sensors["Schedule Max Discharge"] = Sensor(
|
|
364
|
-
value=schedule.get("max_discharge"),
|
|
365
|
-
type=SensorType.POWER,
|
|
366
|
-
device_identifier=ems_device_id,
|
|
367
|
-
)
|
|
368
|
-
|
|
369
|
-
async def _execute_console_command(self, command: str) -> dict[str, Any]:
|
|
370
|
-
"""Execute a console command via the HTTP API.
|
|
371
|
-
|
|
372
|
-
Args:
|
|
373
|
-
command: The console command to execute
|
|
374
|
-
|
|
375
|
-
Returns:
|
|
376
|
-
The JSON response from the console endpoint
|
|
377
|
-
|
|
378
|
-
Raises:
|
|
379
|
-
HomevoltConnectionError: If connection fails
|
|
380
|
-
HomevoltAuthenticationError: If authentication fails
|
|
381
|
-
HomevoltDataError: If response parsing fails
|
|
382
|
-
"""
|
|
383
|
-
try:
|
|
384
|
-
url = f"{self.hostname}{ENDPOINT_CONSOLE}"
|
|
385
|
-
async with self._websession.post(
|
|
386
|
-
url,
|
|
387
|
-
auth=self._auth,
|
|
388
|
-
json={"cmd": command},
|
|
389
|
-
) as response:
|
|
390
|
-
if response.status == 401:
|
|
391
|
-
raise HomevoltAuthenticationError("Authentication failed")
|
|
392
|
-
response.raise_for_status()
|
|
393
|
-
return await response.json()
|
|
394
|
-
except aiohttp.ClientError as err:
|
|
395
|
-
raise HomevoltConnectionError(f"Failed to execute command: {err}") from err
|
|
396
|
-
except Exception as err:
|
|
397
|
-
raise HomevoltDataError(f"Failed to parse command response: {err}") from err
|
|
398
|
-
|
|
399
|
-
async def set_battery_mode(
|
|
400
|
-
self,
|
|
401
|
-
mode: int,
|
|
402
|
-
*,
|
|
403
|
-
setpoint: int | None = None,
|
|
404
|
-
max_charge: int | None = None,
|
|
405
|
-
max_discharge: int | None = None,
|
|
406
|
-
min_soc: int | None = None,
|
|
407
|
-
max_soc: int | None = None,
|
|
408
|
-
offline: bool = False,
|
|
409
|
-
) -> dict[str, Any]:
|
|
410
|
-
"""Set immediate battery control mode.
|
|
411
|
-
|
|
412
|
-
Args:
|
|
413
|
-
mode: Schedule type (0=Idle, 1=Inverter Charge, 2=Inverter Discharge,
|
|
414
|
-
3=Grid Charge, 4=Grid Discharge, 5=Grid Charge/Discharge,
|
|
415
|
-
6=Frequency Reserve, 7=Solar Charge, 8=Solar Charge/Discharge,
|
|
416
|
-
9=Full Solar Export)
|
|
417
|
-
setpoint: Power setpoint in Watts (for grid modes)
|
|
418
|
-
max_charge: Maximum charge power in Watts
|
|
419
|
-
max_discharge: Maximum discharge power in Watts
|
|
420
|
-
min_soc: Minimum state of charge percentage
|
|
421
|
-
max_soc: Maximum state of charge percentage
|
|
422
|
-
offline: Take inverter offline during idle mode
|
|
423
|
-
|
|
424
|
-
Returns:
|
|
425
|
-
Response from the console command
|
|
426
|
-
|
|
427
|
-
Raises:
|
|
428
|
-
HomevoltConnectionError: If connection fails
|
|
429
|
-
HomevoltAuthenticationError: If authentication fails
|
|
430
|
-
HomevoltDataError: If command execution fails
|
|
431
|
-
"""
|
|
432
|
-
if mode not in SCHEDULE_TYPE:
|
|
433
|
-
raise ValueError(f"Invalid mode: {mode}. Must be 0-9")
|
|
434
|
-
|
|
435
|
-
cmd_parts = [f"sched_set {mode}"]
|
|
436
|
-
|
|
437
|
-
if setpoint is not None:
|
|
438
|
-
cmd_parts.append(f"-s {setpoint}")
|
|
439
|
-
if max_charge is not None:
|
|
440
|
-
cmd_parts.append(f"-c {max_charge}")
|
|
441
|
-
if max_discharge is not None:
|
|
442
|
-
cmd_parts.append(f"-d {max_discharge}")
|
|
443
|
-
if min_soc is not None:
|
|
444
|
-
cmd_parts.append(f"--min {min_soc}")
|
|
445
|
-
if max_soc is not None:
|
|
446
|
-
cmd_parts.append(f"--max {max_soc}")
|
|
447
|
-
if offline:
|
|
448
|
-
cmd_parts.append("-o")
|
|
449
|
-
|
|
450
|
-
command = " ".join(cmd_parts)
|
|
451
|
-
return await self._execute_console_command(command)
|
|
452
|
-
|
|
453
|
-
async def add_schedule(
|
|
454
|
-
self,
|
|
455
|
-
mode: int,
|
|
456
|
-
*,
|
|
457
|
-
from_time: str | None = None,
|
|
458
|
-
to_time: str | None = None,
|
|
459
|
-
setpoint: int | None = None,
|
|
460
|
-
max_charge: int | None = None,
|
|
461
|
-
max_discharge: int | None = None,
|
|
462
|
-
min_soc: int | None = None,
|
|
463
|
-
max_soc: int | None = None,
|
|
464
|
-
offline: bool = False,
|
|
465
|
-
) -> dict[str, Any]:
|
|
466
|
-
"""Add a scheduled battery control entry.
|
|
467
|
-
|
|
468
|
-
Args:
|
|
469
|
-
mode: Schedule type (0=Idle, 1=Inverter Charge, 2=Inverter Discharge,
|
|
470
|
-
3=Grid Charge, 4=Grid Discharge, 5=Grid Charge/Discharge,
|
|
471
|
-
6=Frequency Reserve, 7=Solar Charge, 8=Solar Charge/Discharge,
|
|
472
|
-
9=Full Solar Export)
|
|
473
|
-
from_time: Start time in ISO format (YYYY-MM-DDTHH:mm:ss)
|
|
474
|
-
to_time: End time in ISO format (YYYY-MM-DDTHH:mm:ss)
|
|
475
|
-
setpoint: Power setpoint in Watts (for grid modes)
|
|
476
|
-
max_charge: Maximum charge power in Watts
|
|
477
|
-
max_discharge: Maximum discharge power in Watts
|
|
478
|
-
min_soc: Minimum state of charge percentage
|
|
479
|
-
max_soc: Maximum state of charge percentage
|
|
480
|
-
offline: Take inverter offline during idle mode
|
|
481
|
-
|
|
482
|
-
Returns:
|
|
483
|
-
Response from the console command
|
|
484
|
-
|
|
485
|
-
Raises:
|
|
486
|
-
HomevoltConnectionError: If connection fails
|
|
487
|
-
HomevoltAuthenticationError: If authentication fails
|
|
488
|
-
HomevoltDataError: If command execution fails
|
|
489
|
-
"""
|
|
490
|
-
if mode not in SCHEDULE_TYPE:
|
|
491
|
-
raise ValueError(f"Invalid mode: {mode}. Must be 0-9")
|
|
492
|
-
|
|
493
|
-
cmd_parts = [f"sched_add {mode}"]
|
|
494
|
-
|
|
495
|
-
if from_time:
|
|
496
|
-
cmd_parts.append(f"--from {from_time}")
|
|
497
|
-
if to_time:
|
|
498
|
-
cmd_parts.append(f"--to {to_time}")
|
|
499
|
-
if setpoint is not None:
|
|
500
|
-
cmd_parts.append(f"-s {setpoint}")
|
|
501
|
-
if max_charge is not None:
|
|
502
|
-
cmd_parts.append(f"-c {max_charge}")
|
|
503
|
-
if max_discharge is not None:
|
|
504
|
-
cmd_parts.append(f"-d {max_discharge}")
|
|
505
|
-
if min_soc is not None:
|
|
506
|
-
cmd_parts.append(f"--min {min_soc}")
|
|
507
|
-
if max_soc is not None:
|
|
508
|
-
cmd_parts.append(f"--max {max_soc}")
|
|
509
|
-
if offline:
|
|
510
|
-
cmd_parts.append("-o")
|
|
511
|
-
|
|
512
|
-
command = " ".join(cmd_parts)
|
|
513
|
-
return await self._execute_console_command(command)
|
|
514
|
-
|
|
515
|
-
async def delete_schedule(self, schedule_id: int) -> dict[str, Any]:
|
|
516
|
-
"""Delete a schedule by ID.
|
|
517
|
-
|
|
518
|
-
Args:
|
|
519
|
-
schedule_id: The ID of the schedule to delete
|
|
520
|
-
|
|
521
|
-
Returns:
|
|
522
|
-
Response from the console command
|
|
523
|
-
|
|
524
|
-
Raises:
|
|
525
|
-
HomevoltConnectionError: If connection fails
|
|
526
|
-
HomevoltAuthenticationError: If authentication fails
|
|
527
|
-
HomevoltDataError: If command execution fails
|
|
528
|
-
"""
|
|
529
|
-
return await self._execute_console_command(f"sched_del {schedule_id}")
|
|
530
|
-
|
|
531
|
-
async def clear_all_schedules(self) -> dict[str, Any]:
|
|
532
|
-
"""Clear all schedules.
|
|
533
|
-
|
|
534
|
-
Returns:
|
|
535
|
-
Response from the console command
|
|
536
|
-
|
|
537
|
-
Raises:
|
|
538
|
-
HomevoltConnectionError: If connection fails
|
|
539
|
-
HomevoltAuthenticationError: If authentication fails
|
|
540
|
-
HomevoltDataError: If command execution fails
|
|
541
|
-
"""
|
|
542
|
-
return await self._execute_console_command("sched_clear")
|
|
543
|
-
|
|
544
|
-
async def enable_local_mode(self) -> dict[str, Any]:
|
|
545
|
-
"""Enable local mode to prevent remote schedule overrides.
|
|
546
|
-
|
|
547
|
-
When enabled, remote schedules from Tibber/partners via MQTT will be blocked,
|
|
548
|
-
and only local schedules will be used.
|
|
549
|
-
|
|
550
|
-
Returns:
|
|
551
|
-
Response from the params endpoint
|
|
552
|
-
|
|
553
|
-
Raises:
|
|
554
|
-
HomevoltConnectionError: If connection fails
|
|
555
|
-
HomevoltAuthenticationError: If authentication fails
|
|
556
|
-
HomevoltDataError: If parameter setting fails
|
|
557
|
-
"""
|
|
558
|
-
return await self.set_parameter("settings_local", 1)
|
|
559
|
-
|
|
560
|
-
async def disable_local_mode(self) -> dict[str, Any]:
|
|
561
|
-
"""Disable local mode to allow remote schedule overrides.
|
|
562
|
-
|
|
563
|
-
When disabled, remote schedules from Tibber/partners via MQTT will replace
|
|
564
|
-
local schedules.
|
|
565
|
-
|
|
566
|
-
Returns:
|
|
567
|
-
Response from the params endpoint
|
|
568
|
-
|
|
569
|
-
Raises:
|
|
570
|
-
HomevoltConnectionError: If connection fails
|
|
571
|
-
HomevoltAuthenticationError: If authentication fails
|
|
572
|
-
HomevoltDataError: If parameter setting fails
|
|
573
|
-
"""
|
|
574
|
-
return await self.set_parameter("settings_local", 0)
|
|
575
|
-
|
|
576
|
-
async def set_parameter(self, key: str, value: Any) -> dict[str, Any]:
|
|
577
|
-
"""Set a device parameter.
|
|
578
|
-
|
|
579
|
-
Args:
|
|
580
|
-
key: Parameter name
|
|
581
|
-
value: Parameter value
|
|
582
|
-
|
|
583
|
-
Returns:
|
|
584
|
-
Response from the params endpoint
|
|
585
|
-
|
|
586
|
-
Raises:
|
|
587
|
-
HomevoltConnectionError: If connection fails
|
|
588
|
-
HomevoltAuthenticationError: If authentication fails
|
|
589
|
-
HomevoltDataError: If parameter setting fails
|
|
590
|
-
"""
|
|
591
|
-
try:
|
|
592
|
-
url = f"{self.hostname}{ENDPOINT_PARAMS}"
|
|
593
|
-
async with self._websession.post(
|
|
594
|
-
url,
|
|
595
|
-
auth=self._auth,
|
|
596
|
-
json={key: value},
|
|
597
|
-
) as response:
|
|
598
|
-
if response.status == 401:
|
|
599
|
-
raise HomevoltAuthenticationError("Authentication failed")
|
|
600
|
-
response.raise_for_status()
|
|
601
|
-
return await response.json()
|
|
602
|
-
except aiohttp.ClientError as err:
|
|
603
|
-
raise HomevoltConnectionError(f"Failed to set parameter: {err}") from err
|
|
604
|
-
except Exception as err:
|
|
605
|
-
raise HomevoltDataError(f"Failed to parse parameter response: {err}") from err
|
|
606
|
-
|
|
607
|
-
async def get_parameter(self, key: str) -> Any:
|
|
608
|
-
"""Get a device parameter value.
|
|
609
|
-
|
|
610
|
-
Args:
|
|
611
|
-
key: Parameter name
|
|
612
|
-
|
|
613
|
-
Returns:
|
|
614
|
-
Parameter value
|
|
615
|
-
|
|
616
|
-
Raises:
|
|
617
|
-
HomevoltConnectionError: If connection fails
|
|
618
|
-
HomevoltAuthenticationError: If authentication fails
|
|
619
|
-
HomevoltDataError: If parameter retrieval fails
|
|
620
|
-
"""
|
|
621
|
-
try:
|
|
622
|
-
url = f"{self.hostname}{ENDPOINT_PARAMS}"
|
|
623
|
-
async with self._websession.get(url, auth=self._auth) as response:
|
|
624
|
-
if response.status == 401:
|
|
625
|
-
raise HomevoltAuthenticationError("Authentication failed")
|
|
626
|
-
response.raise_for_status()
|
|
627
|
-
params = await response.json()
|
|
628
|
-
return params.get(key)
|
|
629
|
-
except aiohttp.ClientError as err:
|
|
630
|
-
raise HomevoltConnectionError(f"Failed to get parameter: {err}") from err
|
|
631
|
-
except Exception as err:
|
|
632
|
-
raise HomevoltDataError(f"Failed to parse parameter response: {err}") from err
|
|
633
|
-
|
|
634
|
-
async def charge_battery(
|
|
635
|
-
self,
|
|
636
|
-
*,
|
|
637
|
-
max_power: int | None = None,
|
|
638
|
-
max_soc: int | None = None,
|
|
639
|
-
min_soc: int | None = None,
|
|
640
|
-
) -> dict[str, Any]:
|
|
641
|
-
"""Charge battery using inverter (immediate).
|
|
642
|
-
|
|
643
|
-
Args:
|
|
644
|
-
max_power: Maximum charge power in Watts
|
|
645
|
-
max_soc: Maximum state of charge percentage (stops at this level)
|
|
646
|
-
min_soc: Minimum state of charge percentage (only charges if below this)
|
|
647
|
-
|
|
648
|
-
Returns:
|
|
649
|
-
Response from the console command
|
|
650
|
-
"""
|
|
651
|
-
return await self.set_battery_mode(
|
|
652
|
-
1, # Inverter Charge
|
|
653
|
-
max_charge=max_power,
|
|
654
|
-
max_soc=max_soc,
|
|
655
|
-
min_soc=min_soc,
|
|
656
|
-
)
|
|
657
|
-
|
|
658
|
-
async def discharge_battery(
|
|
659
|
-
self,
|
|
660
|
-
*,
|
|
661
|
-
max_power: int | None = None,
|
|
662
|
-
min_soc: int | None = None,
|
|
663
|
-
max_soc: int | None = None,
|
|
664
|
-
) -> dict[str, Any]:
|
|
665
|
-
"""Discharge battery using inverter (immediate).
|
|
666
|
-
|
|
667
|
-
Args:
|
|
668
|
-
max_power: Maximum discharge power in Watts
|
|
669
|
-
min_soc: Minimum state of charge percentage (stops at this level)
|
|
670
|
-
max_soc: Maximum state of charge percentage (only discharges if above this)
|
|
671
|
-
|
|
672
|
-
Returns:
|
|
673
|
-
Response from the console command
|
|
674
|
-
"""
|
|
675
|
-
return await self.set_battery_mode(
|
|
676
|
-
2, # Inverter Discharge
|
|
677
|
-
max_discharge=max_power,
|
|
678
|
-
min_soc=min_soc,
|
|
679
|
-
max_soc=max_soc,
|
|
680
|
-
)
|
|
681
|
-
|
|
682
|
-
async def set_battery_idle(self, *, offline: bool = False) -> dict[str, Any]:
|
|
683
|
-
"""Set battery to idle mode (immediate).
|
|
684
|
-
|
|
685
|
-
Args:
|
|
686
|
-
offline: If True, take inverter offline during idle
|
|
687
|
-
|
|
688
|
-
Returns:
|
|
689
|
-
Response from the console command
|
|
690
|
-
"""
|
|
691
|
-
return await self.set_battery_mode(0, offline=offline)
|
|
692
|
-
|
|
693
|
-
async def charge_from_grid(
|
|
694
|
-
self,
|
|
695
|
-
*,
|
|
696
|
-
setpoint: int,
|
|
697
|
-
max_power: int | None = None,
|
|
698
|
-
max_soc: int | None = None,
|
|
699
|
-
) -> dict[str, Any]:
|
|
700
|
-
"""Charge battery from grid with power setpoint (immediate).
|
|
701
|
-
|
|
702
|
-
Args:
|
|
703
|
-
setpoint: Power setpoint in Watts
|
|
704
|
-
max_power: Maximum charge power in Watts
|
|
705
|
-
max_soc: Maximum state of charge percentage
|
|
706
|
-
|
|
707
|
-
Returns:
|
|
708
|
-
Response from the console command
|
|
709
|
-
"""
|
|
710
|
-
return await self.set_battery_mode(
|
|
711
|
-
3, # Grid Charge
|
|
712
|
-
setpoint=setpoint,
|
|
713
|
-
max_charge=max_power,
|
|
714
|
-
max_soc=max_soc,
|
|
715
|
-
)
|
|
716
|
-
|
|
717
|
-
async def discharge_to_grid(
|
|
718
|
-
self,
|
|
719
|
-
*,
|
|
720
|
-
setpoint: int,
|
|
721
|
-
max_power: int | None = None,
|
|
722
|
-
min_soc: int | None = None,
|
|
723
|
-
) -> dict[str, Any]:
|
|
724
|
-
"""Discharge battery to grid with power setpoint (immediate).
|
|
725
|
-
|
|
726
|
-
Args:
|
|
727
|
-
setpoint: Power setpoint in Watts
|
|
728
|
-
max_power: Maximum discharge power in Watts
|
|
729
|
-
min_soc: Minimum state of charge percentage
|
|
730
|
-
|
|
731
|
-
Returns:
|
|
732
|
-
Response from the console command
|
|
733
|
-
"""
|
|
734
|
-
return await self.set_battery_mode(
|
|
735
|
-
4, # Grid Discharge
|
|
736
|
-
setpoint=setpoint,
|
|
737
|
-
max_discharge=max_power,
|
|
738
|
-
min_soc=min_soc,
|
|
739
|
-
)
|
|
740
|
-
|
|
741
|
-
async def charge_from_solar(
|
|
742
|
-
self,
|
|
743
|
-
*,
|
|
744
|
-
max_power: int | None = None,
|
|
745
|
-
max_soc: int | None = None,
|
|
746
|
-
) -> dict[str, Any]:
|
|
747
|
-
"""Charge battery from solar only (immediate).
|
|
748
|
-
|
|
749
|
-
Args:
|
|
750
|
-
max_power: Maximum charge power in Watts
|
|
751
|
-
max_soc: Maximum state of charge percentage
|
|
752
|
-
|
|
753
|
-
Returns:
|
|
754
|
-
Response from the console command
|
|
755
|
-
"""
|
|
756
|
-
return await self.set_battery_mode(
|
|
757
|
-
7, # Solar Charge
|
|
758
|
-
max_charge=max_power,
|
|
759
|
-
max_soc=max_soc,
|
|
760
|
-
)
|