Skip to content

Commit

Permalink
Improved AC charging controls (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
cdpuk authored Nov 29, 2022
1 parent 79fe6a1 commit 935c5f5
Show file tree
Hide file tree
Showing 9 changed files with 477 additions and 117 deletions.
7 changes: 6 additions & 1 deletion custom_components/givenergy_local/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
from .coordinator import GivEnergyUpdateCoordinator
from .services import async_setup_services, async_unload_services

_PLATFORMS: list[Platform] = [Platform.SENSOR]
_PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
]


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
Expand Down
164 changes: 164 additions & 0 deletions custom_components/givenergy_local/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""Binary sensor platform."""
from __future__ import annotations

from datetime import datetime, time, timedelta

from typing import Any, Mapping

from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_time

from .const import DOMAIN, LOGGER, Icon
from .coordinator import GivEnergyUpdateCoordinator
from .entity import InverterEntity

_CHARGE_SLOT_BINARY_SENSORS = [
BinarySensorEntityDescription(
key="charge_slot_1",
icon=Icon.BATTERY_PLUS,
name="Battery Charge Slot 1",
),
BinarySensorEntityDescription(
key="charge_slot_2",
icon=Icon.BATTERY_PLUS,
name="Battery Charge Slot 2",
),
BinarySensorEntityDescription(
key="discharge_slot_1",
icon=Icon.BATTERY_MINUS,
name="Battery Discharge Slot 1",
),
BinarySensorEntityDescription(
key="discharge_slot_2",
icon=Icon.BATTERY_MINUS,
name="Battery Discharge Slot 2",
),
]


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Add sensors for passed config_entry in HA."""
coordinator: GivEnergyUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]

# Add inverter sensors for charge/discharge slots.
async_add_entities(
InverterChargeSlotBinarySensor(coordinator, config_entry, entity_description)
for entity_description in _CHARGE_SLOT_BINARY_SENSORS
)


class InverterChargeSlotBinarySensor(InverterEntity, BinarySensorEntity):
"""A binary sensor that reports whether a charge/discharge slot is currently active."""

entity_description: BinarySensorEntityDescription
_cancel_scheduled_update: CALLBACK_TYPE | None = None

def __init__(
self,
coordinator: GivEnergyUpdateCoordinator,
config_entry: ConfigEntry,
entity_description: BinarySensorEntityDescription,
) -> None:
"""Initialize a sensor based on an entity description."""
super().__init__(coordinator, config_entry)
self._attr_unique_id = (
f"{self.data.inverter_serial_number}_{entity_description.key}"
)
self.entity_description = entity_description

async def async_added_to_hass(self) -> None:
"""Entity has been added to HA."""
await super().async_added_to_hass()
self._schedule_next_update()

async def async_will_remove_from_hass(self) -> None:
"""Entity has been removed from HA."""
await super().async_will_remove_from_hass()
if self._cancel_scheduled_update is not None:
self._cancel_scheduled_update()

async def _async_scheduled_update(self, now: datetime) -> None:
"""
Respond to a scheduled update.
We've been woken up by a timer because we've just passed over the start
or end time for the slot. Ask HA to reassess the entity state and schedule
another update.
"""
self.async_schedule_update_ha_state()
self._schedule_next_update()

def _schedule_next_update(self) -> None:
"""
Schedule a future update to the entity state, if required.
Work out when we next need to update the state due to the current time
passing over the start of end time of the slot.
"""
now = datetime.now()

# Get slot details
current_time = now.time()
start = self.slot[0]
end = self.slot[1]

# We don't need to be notified about entering/leaving an undefined slot
if start == end:
return

# Work out the next time at which we need to check again
if current_time < start:
next_change = datetime.combine(now.date(), start)
elif current_time < end:
next_change = datetime.combine(now.date(), end)
else:
next_change = datetime.combine(now.date() + timedelta(days=1), start)

# Schedule the next update
self._cancel_scheduled_update = async_track_point_in_time(
self.hass,
self._async_scheduled_update,
next_change,
)
LOGGER.debug(
"Scheduled next update for %s at %s",
self.entity_description.key,
next_change,
)

def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self._cancel_scheduled_update is not None:
self._cancel_scheduled_update()

self._schedule_next_update()
self.async_write_ha_state()

@property
def slot(self) -> tuple[time, time]:
"""Get the slot definition."""
return self.data.dict().get(self.entity_description.key) # type: ignore

@property
def is_on(self) -> bool | None:
"""Determine whether we're currently within the slot."""
now = datetime.now().time()
return self.slot[0] <= now < self.slot[1]

@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Attach charge slot configuration."""
return {
"start": self.slot[0].strftime("%H:%M"),
"end": self.slot[1].strftime("%H:%M"),
}
18 changes: 18 additions & 0 deletions custom_components/givenergy_local/const.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Constants for the GivEnergy integration."""

from enum import Enum
from logging import Logger, getLogger

DOMAIN = "givenergy_local"
Expand All @@ -9,3 +10,20 @@
CONF_NUM_BATTERIES = "num_batteries"

MANUFACTURER = "GivEnergy"


class Icon(str, Enum):
"""Icon styles."""

PV = "mdi:solar-power"
AC = "mdi:power-plug-outline"
BATTERY = "mdi:battery-high"
BATTERY_CYCLES = "mdi:battery-sync"
BATTERY_TEMPERATURE = "mdi:thermometer"
BATTERY_MINUS = "mdi:battery-minus"
BATTERY_PLUS = "mdi:battery-plus"
INVERTER = "mdi:flash"
GRID_IMPORT = "mdi:transmission-tower-export"
GRID_EXPORT = "mdi:transmission-tower-import"
EPS = "mdi:transmission-tower-off"
TEMPERATURE = "mdi:thermometer"
45 changes: 45 additions & 0 deletions custom_components/givenergy_local/givenergy_ext.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""GivEnergy client wrapper functions."""
import asyncio

from typing import Callable

from givenergy_modbus.client import GivEnergyClient
from homeassistant.core import HomeAssistant

from .const import LOGGER
from .coordinator import GivEnergyUpdateCoordinator

# A bit of a workaround for flaky modbus connections.
# We try to call services a few times, and only allow the exception to escape after we've
# made this many attempts.
_MAX_ATTEMPTS = 5
_DELAY_BETWEEN_ATTEMPTS = 2.0


async def async_reliable_call(
hass: HomeAssistant,
coordinator: GivEnergyUpdateCoordinator,
func: Callable[[GivEnergyClient], None],
) -> None:
"""
Attempt to reliably call a function on a GivEnergy client.
When setting values on the inverter, failures are frustratingly common.
Using this method will make a number of retries before eventually giving up.
"""
attempts = _MAX_ATTEMPTS

while attempts > 0:
LOGGER.debug("Attempting function call (%d attempts left)", attempts)
client = GivEnergyClient(coordinator.host)

try:
await hass.async_add_executor_job(func, client)
await coordinator.async_request_full_refresh()
break
except AssertionError as err:
LOGGER.error("Function failed %s", err)
attempts = attempts - 1
await asyncio.sleep(_DELAY_BETWEEN_ATTEMPTS)
finally:
client.modbus_client.close()
96 changes: 96 additions & 0 deletions custom_components/givenergy_local/number.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Home Assistant number entity descriptions."""
from __future__ import annotations

from givenergy_modbus.client import GivEnergyClient
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType

from .const import DOMAIN, Icon
from .coordinator import GivEnergyUpdateCoordinator
from .entity import InverterEntity
from .givenergy_ext import async_reliable_call


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Add sensors for passed config_entry in HA."""
coordinator: GivEnergyUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
[
ACChargeLimitNumber(coordinator, config_entry),
]
)


class InverterBasicNumber(InverterEntity, NumberEntity):
"""A number that derives its value from the register values fetched from the inverter."""

def __init__(
self,
coordinator: GivEnergyUpdateCoordinator,
config_entry: ConfigEntry,
entity_description: NumberEntityDescription,
) -> None:
"""Initialize a sensor based on an entity description."""
super().__init__(coordinator, config_entry)
self._attr_unique_id = (
f"{self.data.inverter_serial_number}_{entity_description.key}"
)
self.entity_description = entity_description

@property
def native_value(self) -> StateType:
"""
Get the current value.
This returns the register value as referenced by the 'key' property of
the associated entity description.
"""
return self.data.dict().get(self.entity_description.key)


class ACChargeLimitNumber(InverterBasicNumber):
"""Number to represent and control the AC Charge SOC Limit."""

def __init__(
self,
coordinator: GivEnergyUpdateCoordinator,
config_entry: ConfigEntry,
) -> None:
"""Initialize the AC Charge Limit number."""
super().__init__(
coordinator,
config_entry,
NumberEntityDescription(
key="charge_target_soc",
name="Battery AC Charge Limit",
icon=Icon.BATTERY_PLUS,
native_unit_of_measurement=PERCENTAGE,
),
)

# Values correspond to SOC percentage
self._attr_native_min_value = 0
self._attr_native_max_value = 100

# A 5% step size makes the slider a bit nicer to use
self._attr_native_step = 5

async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""

def set_battery_target_soc(client: GivEnergyClient) -> None:
client.set_battery_target_soc(int(value))

await async_reliable_call(
self.hass,
self.coordinator,
set_battery_target_soc,
)
Loading

0 comments on commit 935c5f5

Please sign in to comment.