generated from ludeeus/integration_blueprint
-
Notifications
You must be signed in to change notification settings - Fork 14
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
9 changed files
with
477 additions
and
117 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"), | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
) |
Oops, something went wrong.