Homevolt 0.1.0__py3-none-any.whl → 0.2.1__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 +2 -3
- homevolt/const.py +11 -6
- homevolt/device.py +456 -25
- homevolt/exceptions.py +4 -5
- homevolt/homevolt.py +3 -3
- homevolt/models.py +0 -1
- {homevolt-0.1.0.dist-info → homevolt-0.2.1.dist-info}/METADATA +107 -9
- homevolt-0.2.1.dist-info/RECORD +10 -0
- homevolt-0.1.0.dist-info/RECORD +0 -10
- {homevolt-0.1.0.dist-info → homevolt-0.2.1.dist-info}/WHEEL +0 -0
- {homevolt-0.1.0.dist-info → homevolt-0.2.1.dist-info}/top_level.txt +0 -0
homevolt/__init__.py
CHANGED
|
@@ -5,7 +5,7 @@ from .exceptions import (
|
|
|
5
5
|
HomevoltAuthenticationError,
|
|
6
6
|
HomevoltConnectionError,
|
|
7
7
|
HomevoltDataError,
|
|
8
|
-
|
|
8
|
+
HomevoltError,
|
|
9
9
|
)
|
|
10
10
|
from .homevolt import Homevolt
|
|
11
11
|
from .models import DeviceMetadata, Sensor, SensorType
|
|
@@ -17,8 +17,7 @@ __all__ = [
|
|
|
17
17
|
"HomevoltAuthenticationError",
|
|
18
18
|
"HomevoltConnectionError",
|
|
19
19
|
"HomevoltDataError",
|
|
20
|
-
"
|
|
20
|
+
"HomevoltError",
|
|
21
21
|
"Sensor",
|
|
22
22
|
"SensorType",
|
|
23
23
|
]
|
|
24
|
-
|
homevolt/const.py
CHANGED
|
@@ -3,14 +3,20 @@
|
|
|
3
3
|
# API endpoints
|
|
4
4
|
ENDPOINT_EMS = "/ems.json"
|
|
5
5
|
ENDPOINT_SCHEDULE = "/schedule.json"
|
|
6
|
+
ENDPOINT_CONSOLE = "/console.json"
|
|
7
|
+
ENDPOINT_PARAMS = "/params.json"
|
|
6
8
|
|
|
7
9
|
SCHEDULE_TYPE = {
|
|
8
10
|
0: "Idle",
|
|
9
|
-
1: "Charge
|
|
10
|
-
2: "Discharge
|
|
11
|
-
3: "
|
|
12
|
-
4: "
|
|
13
|
-
5: "Charge/Discharge
|
|
11
|
+
1: "Inverter Charge",
|
|
12
|
+
2: "Inverter Discharge",
|
|
13
|
+
3: "Grid Charge",
|
|
14
|
+
4: "Grid Discharge",
|
|
15
|
+
5: "Grid Charge/Discharge",
|
|
16
|
+
6: "Frequency Reserve",
|
|
17
|
+
7: "Solar Charge",
|
|
18
|
+
8: "Solar Charge/Discharge",
|
|
19
|
+
9: "Full Solar Export",
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
# Device type mappings for sensors
|
|
@@ -20,4 +26,3 @@ DEVICE_MAP = {
|
|
|
20
26
|
"load": "load",
|
|
21
27
|
"house": "load",
|
|
22
28
|
}
|
|
23
|
-
|
homevolt/device.py
CHANGED
|
@@ -7,7 +7,14 @@ from typing import Any
|
|
|
7
7
|
|
|
8
8
|
import aiohttp
|
|
9
9
|
|
|
10
|
-
from .const import
|
|
10
|
+
from .const import (
|
|
11
|
+
DEVICE_MAP,
|
|
12
|
+
ENDPOINT_CONSOLE,
|
|
13
|
+
ENDPOINT_EMS,
|
|
14
|
+
ENDPOINT_PARAMS,
|
|
15
|
+
ENDPOINT_SCHEDULE,
|
|
16
|
+
SCHEDULE_TYPE,
|
|
17
|
+
)
|
|
11
18
|
from .exceptions import (
|
|
12
19
|
HomevoltAuthenticationError,
|
|
13
20
|
HomevoltConnectionError,
|
|
@@ -34,7 +41,7 @@ class Device:
|
|
|
34
41
|
password: Optional password for authentication
|
|
35
42
|
websession: aiohttp ClientSession for making requests
|
|
36
43
|
"""
|
|
37
|
-
self.
|
|
44
|
+
self.ip_address = ip_address
|
|
38
45
|
self._password = password
|
|
39
46
|
self._websession = websession
|
|
40
47
|
self._auth = aiohttp.BasicAuth("admin", password) if password else None
|
|
@@ -52,7 +59,7 @@ class Device:
|
|
|
52
59
|
async def fetch_ems_data(self) -> None:
|
|
53
60
|
"""Fetch EMS data from the device."""
|
|
54
61
|
try:
|
|
55
|
-
url = f"http://{self.
|
|
62
|
+
url = f"http://{self.ip_address}{ENDPOINT_EMS}"
|
|
56
63
|
async with self._websession.get(url, auth=self._auth) as response:
|
|
57
64
|
if response.status == 401:
|
|
58
65
|
raise HomevoltAuthenticationError("Authentication failed")
|
|
@@ -68,7 +75,7 @@ class Device:
|
|
|
68
75
|
async def fetch_schedule_data(self) -> None:
|
|
69
76
|
"""Fetch schedule data from the device."""
|
|
70
77
|
try:
|
|
71
|
-
url = f"http://{self.
|
|
78
|
+
url = f"http://{self.ip_address}{ENDPOINT_SCHEDULE}"
|
|
72
79
|
async with self._websession.get(url, auth=self._auth) as response:
|
|
73
80
|
if response.status == 401:
|
|
74
81
|
raise HomevoltAuthenticationError("Authentication failed")
|
|
@@ -210,26 +217,54 @@ class Device:
|
|
|
210
217
|
name=f"Homevolt Battery {bat_id}",
|
|
211
218
|
model="Homevolt Battery",
|
|
212
219
|
)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
+
)
|
|
233
268
|
|
|
234
269
|
# External sensors (grid, solar, load)
|
|
235
270
|
for sensor in ems_data.get("sensors", []):
|
|
@@ -304,7 +339,11 @@ class Device:
|
|
|
304
339
|
device_identifier=ems_device_id,
|
|
305
340
|
)
|
|
306
341
|
|
|
307
|
-
schedule =
|
|
342
|
+
schedule = (
|
|
343
|
+
schedule_data.get("schedule", [{}])[0]
|
|
344
|
+
if schedule_data.get("schedule")
|
|
345
|
+
else {"type": -1, "params": {}}
|
|
346
|
+
)
|
|
308
347
|
|
|
309
348
|
self.sensors["Schedule Type"] = Sensor(
|
|
310
349
|
value=SCHEDULE_TYPE.get(schedule.get("type", -1)),
|
|
@@ -327,3 +366,395 @@ class Device:
|
|
|
327
366
|
device_identifier=ems_device_id,
|
|
328
367
|
)
|
|
329
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"http://{self.ip_address}{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"http://{self.ip_address}{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"http://{self.ip_address}{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
|
+
)
|
homevolt/exceptions.py
CHANGED
|
@@ -1,26 +1,25 @@
|
|
|
1
1
|
"""Custom exceptions for the Homevolt library."""
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
class
|
|
4
|
+
class HomevoltError(Exception):
|
|
5
5
|
"""Base exception for all Homevolt errors."""
|
|
6
6
|
|
|
7
7
|
pass
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
class HomevoltConnectionError(
|
|
10
|
+
class HomevoltConnectionError(HomevoltError):
|
|
11
11
|
"""Raised when there's a connection or network error."""
|
|
12
12
|
|
|
13
13
|
pass
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
class HomevoltAuthenticationError(
|
|
16
|
+
class HomevoltAuthenticationError(HomevoltError):
|
|
17
17
|
"""Raised when authentication fails."""
|
|
18
18
|
|
|
19
19
|
pass
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
class HomevoltDataError(
|
|
22
|
+
class HomevoltDataError(HomevoltError):
|
|
23
23
|
"""Raised when there's an error parsing or processing data."""
|
|
24
24
|
|
|
25
25
|
pass
|
|
26
|
-
|
homevolt/homevolt.py
CHANGED
|
@@ -27,7 +27,7 @@ class Homevolt:
|
|
|
27
27
|
password: Optional password for authentication
|
|
28
28
|
websession: Optional aiohttp ClientSession. If not provided, one will be created.
|
|
29
29
|
"""
|
|
30
|
-
self.
|
|
30
|
+
self.ip_address = ip_address
|
|
31
31
|
self._password = password
|
|
32
32
|
self._websession = websession
|
|
33
33
|
self._own_session = websession is None
|
|
@@ -38,8 +38,9 @@ class Homevolt:
|
|
|
38
38
|
"""Fetch and update device information."""
|
|
39
39
|
if self._device is None:
|
|
40
40
|
await self._ensure_session()
|
|
41
|
+
assert self._websession is not None
|
|
41
42
|
self._device = Device(
|
|
42
|
-
ip_address=self.
|
|
43
|
+
ip_address=self.ip_address,
|
|
43
44
|
password=self._password,
|
|
44
45
|
websession=self._websession,
|
|
45
46
|
)
|
|
@@ -79,4 +80,3 @@ class Homevolt:
|
|
|
79
80
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
80
81
|
"""Async context manager exit."""
|
|
81
82
|
await self.close_connection()
|
|
82
|
-
|
homevolt/models.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: Homevolt
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Python library for Homevolt EMS devices
|
|
5
5
|
Author-email: Your Name <your.email@example.com>
|
|
6
6
|
License: GPL-3.0
|
|
@@ -24,10 +24,16 @@ Get real-time data from your Homevolt Energy Management System, including:
|
|
|
24
24
|
- Grid, solar, and load sensor data
|
|
25
25
|
- Schedule information
|
|
26
26
|
|
|
27
|
+
Control your battery with:
|
|
28
|
+
- Immediate battery control (charge, discharge, idle)
|
|
29
|
+
- Scheduled battery operations
|
|
30
|
+
- Local mode management
|
|
31
|
+
- Parameter configuration
|
|
32
|
+
|
|
27
33
|
## Install
|
|
28
34
|
|
|
29
35
|
```bash
|
|
30
|
-
pip install
|
|
36
|
+
pip install homevolt
|
|
31
37
|
```
|
|
32
38
|
|
|
33
39
|
## Example
|
|
@@ -46,20 +52,20 @@ async def main():
|
|
|
46
52
|
websession=session,
|
|
47
53
|
)
|
|
48
54
|
await homevolt_connection.update_info()
|
|
49
|
-
|
|
55
|
+
|
|
50
56
|
device = homevolt_connection.get_device()
|
|
51
57
|
print(f"Device ID: {device.device_id}")
|
|
52
58
|
print(f"Current Power: {device.sensors['Power'].value} W")
|
|
53
59
|
print(f"Battery SOC: {device.sensors['Battery State of Charge'].value * 100}%")
|
|
54
|
-
|
|
60
|
+
|
|
55
61
|
# Access all sensors
|
|
56
62
|
for sensor_name, sensor in device.sensors.items():
|
|
57
63
|
print(f"{sensor_name}: {sensor.value} ({sensor.type.value})")
|
|
58
|
-
|
|
64
|
+
|
|
59
65
|
# Access device metadata
|
|
60
66
|
for device_id, metadata in device.device_metadata.items():
|
|
61
67
|
print(f"{device_id}: {metadata.name} ({metadata.model})")
|
|
62
|
-
|
|
68
|
+
|
|
63
69
|
await homevolt_connection.close_connection()
|
|
64
70
|
|
|
65
71
|
|
|
@@ -83,10 +89,10 @@ async def main():
|
|
|
83
89
|
websession=session,
|
|
84
90
|
) as homevolt_connection:
|
|
85
91
|
await homevolt_connection.update_info()
|
|
86
|
-
|
|
92
|
+
|
|
87
93
|
device = homevolt_connection.get_device()
|
|
88
94
|
await device.update_info() # Refresh data
|
|
89
|
-
|
|
95
|
+
|
|
90
96
|
print(f"Device ID: {device.device_id}")
|
|
91
97
|
print(f"Available sensors: {list(device.sensors.keys())}")
|
|
92
98
|
|
|
@@ -95,6 +101,76 @@ if __name__ == "__main__":
|
|
|
95
101
|
asyncio.run(main())
|
|
96
102
|
```
|
|
97
103
|
|
|
104
|
+
## Battery Control Example
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
import asyncio
|
|
108
|
+
import aiohttp
|
|
109
|
+
import homevolt
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def main():
|
|
113
|
+
async with aiohttp.ClientSession() as session:
|
|
114
|
+
async with homevolt.Homevolt(
|
|
115
|
+
ip_address="192.168.1.100",
|
|
116
|
+
password="optional_password",
|
|
117
|
+
websession=session,
|
|
118
|
+
) as homevolt_connection:
|
|
119
|
+
await homevolt_connection.update_info()
|
|
120
|
+
device = homevolt_connection.get_device()
|
|
121
|
+
|
|
122
|
+
# Enable local mode to prevent remote schedule overrides
|
|
123
|
+
await device.enable_local_mode()
|
|
124
|
+
|
|
125
|
+
# Charge battery immediately (up to 3000W, stop at 90% SOC)
|
|
126
|
+
await device.charge_battery(max_power=3000, max_soc=90)
|
|
127
|
+
|
|
128
|
+
# Or use the full control method
|
|
129
|
+
await device.set_battery_mode(
|
|
130
|
+
mode=1, # Inverter Charge
|
|
131
|
+
max_charge=3000,
|
|
132
|
+
max_soc=90,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Schedule night charging (11 PM - 7 AM)
|
|
136
|
+
from datetime import datetime, timedelta
|
|
137
|
+
tonight = datetime.now().replace(hour=23, minute=0, second=0)
|
|
138
|
+
tomorrow = (tonight + timedelta(days=1)).replace(hour=7, minute=0, second=0)
|
|
139
|
+
|
|
140
|
+
await device.add_schedule(
|
|
141
|
+
mode=1, # Inverter Charge
|
|
142
|
+
from_time=tonight.isoformat(),
|
|
143
|
+
to_time=tomorrow.isoformat(),
|
|
144
|
+
max_charge=3000,
|
|
145
|
+
max_soc=80,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Set battery to idle
|
|
149
|
+
await device.set_battery_idle()
|
|
150
|
+
|
|
151
|
+
# Discharge during peak hours
|
|
152
|
+
await device.discharge_battery(max_power=2500, min_soc=30)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
asyncio.run(main())
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Battery Control Modes
|
|
160
|
+
|
|
161
|
+
The following modes are available for battery control:
|
|
162
|
+
|
|
163
|
+
- `0`: Idle - Battery standby (no charge/discharge)
|
|
164
|
+
- `1`: Inverter Charge - Charge battery via inverter from grid/solar
|
|
165
|
+
- `2`: Inverter Discharge - Discharge battery via inverter to home/grid
|
|
166
|
+
- `3`: Grid Charge - Charge from grid with power setpoint
|
|
167
|
+
- `4`: Grid Discharge - Discharge to grid with power setpoint
|
|
168
|
+
- `5`: Grid Charge/Discharge - Bidirectional grid control
|
|
169
|
+
- `6`: Frequency Reserve - Frequency regulation service mode
|
|
170
|
+
- `7`: Solar Charge - Charge from solar production only
|
|
171
|
+
- `8`: Solar Charge/Discharge - Solar-based grid management
|
|
172
|
+
- `9`: Full Solar Export - Export all solar production
|
|
173
|
+
|
|
98
174
|
## API Reference
|
|
99
175
|
|
|
100
176
|
### Homevolt
|
|
@@ -132,6 +208,28 @@ Represents a Homevolt EMS device.
|
|
|
132
208
|
- `async fetch_ems_data()`: Fetch EMS data specifically
|
|
133
209
|
- `async fetch_schedule_data()`: Fetch schedule data specifically
|
|
134
210
|
|
|
211
|
+
#### Battery Control Methods
|
|
212
|
+
|
|
213
|
+
**Immediate Control:**
|
|
214
|
+
- `async set_battery_mode(mode, **kwargs)`: Set immediate battery control mode
|
|
215
|
+
- `async charge_battery(**kwargs)`: Charge battery using inverter
|
|
216
|
+
- `async discharge_battery(**kwargs)`: Discharge battery using inverter
|
|
217
|
+
- `async set_battery_idle(**kwargs)`: Set battery to idle mode
|
|
218
|
+
- `async charge_from_grid(**kwargs)`: Charge from grid with power setpoint
|
|
219
|
+
- `async discharge_to_grid(**kwargs)`: Discharge to grid with power setpoint
|
|
220
|
+
- `async charge_from_solar(**kwargs)`: Charge from solar only
|
|
221
|
+
|
|
222
|
+
**Scheduled Control:**
|
|
223
|
+
- `async add_schedule(mode, **kwargs)`: Add a scheduled battery control entry
|
|
224
|
+
- `async delete_schedule(schedule_id)`: Delete a schedule by ID
|
|
225
|
+
- `async clear_all_schedules()`: Clear all schedules
|
|
226
|
+
|
|
227
|
+
**Configuration:**
|
|
228
|
+
- `async enable_local_mode()`: Enable local mode (prevents remote overrides)
|
|
229
|
+
- `async disable_local_mode()`: Disable local mode (allows remote overrides)
|
|
230
|
+
- `async set_parameter(key, value)`: Set a device parameter
|
|
231
|
+
- `async get_parameter(key)`: Get a device parameter value
|
|
232
|
+
|
|
135
233
|
### Data Models
|
|
136
234
|
|
|
137
235
|
#### Sensor
|
|
@@ -163,7 +261,7 @@ Enumeration of sensor types:
|
|
|
163
261
|
|
|
164
262
|
### Exceptions
|
|
165
263
|
|
|
166
|
-
- `
|
|
264
|
+
- `HomevoltError`: Base exception for all Homevolt errors
|
|
167
265
|
- `HomevoltConnectionError`: Connection or network errors
|
|
168
266
|
- `HomevoltAuthenticationError`: Authentication failures
|
|
169
267
|
- `HomevoltDataError`: Data parsing errors
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
homevolt/__init__.py,sha256=HAK08jrG9KyS7Nd3tJT4QRhmx3a3AY4tlh_szh_09cg,504
|
|
2
|
+
homevolt/const.py,sha256=hpiyo292WKIOfULnaJVY7n7afkn5cSQtqQFhQlQBpa4,609
|
|
3
|
+
homevolt/device.py,sha256=T-kN6IFX1gf8C_3stUNm2uQG1PWjnYKn-_nxSwAlGks,28356
|
|
4
|
+
homevolt/exceptions.py,sha256=TeG1J92JsbBpJok6Qpbdu4ENm9OjVMNgeHPcUoYjzdw,488
|
|
5
|
+
homevolt/homevolt.py,sha256=WEb89sqVa8dccoYEqvAuZEjPltGamlJPfGII0bqA5_g,2513
|
|
6
|
+
homevolt/models.py,sha256=YrzTuAsFbfG4c-XhZMYtIZAe6qhEsDmZZdL5zPLHnOA,928
|
|
7
|
+
homevolt-0.2.1.dist-info/METADATA,sha256=KXkdDKlzg5USHsGTX8JmGzdLdcOOpN3-QLKBizYNFyM,8062
|
|
8
|
+
homevolt-0.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
9
|
+
homevolt-0.2.1.dist-info/top_level.txt,sha256=eOPhiXXDJEiBEdExWtFc_UqWSnbLbFpEsMm3fCXrBTA,9
|
|
10
|
+
homevolt-0.2.1.dist-info/RECORD,,
|
homevolt-0.1.0.dist-info/RECORD
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
homevolt/__init__.py,sha256=ClavtnqHSLlElc7yiUCnDFzIwHyTPWBqGmvsg_4iqnw,513
|
|
2
|
-
homevolt/const.py,sha256=0PpvS_X9aE0t2B-ekj_BhA1qNKQHqo4r2EoF3hJKxqM,457
|
|
3
|
-
homevolt/device.py,sha256=xWmfguczg-ZNM1Xj1_YTwvNe2GsKCa8lCKxGmsOTfvA,13271
|
|
4
|
-
homevolt/exceptions.py,sha256=YfXXS944vTKwb6sQ9AWfOsAjY1ONSBYu_DdL_g6NaZc,505
|
|
5
|
-
homevolt/homevolt.py,sha256=XnoWwiY5j8lCNH60S9rBumFQNzn47WIfZVWsFrMEIMo,2468
|
|
6
|
-
homevolt/models.py,sha256=8m3uCGrOiB3FPVhz8k4sUIJTnJENHa96uv4z-0NPRuk,929
|
|
7
|
-
homevolt-0.1.0.dist-info/METADATA,sha256=RDFWrj8iKYkKqBoRX9xj6ECOTeAVi_qh_QRc1nY1Yyc,4533
|
|
8
|
-
homevolt-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
9
|
-
homevolt-0.1.0.dist-info/top_level.txt,sha256=eOPhiXXDJEiBEdExWtFc_UqWSnbLbFpEsMm3fCXrBTA,9
|
|
10
|
-
homevolt-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|