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