From 18ef3a583eb0ed523e16dc19ae084564eb3e97c4 Mon Sep 17 00:00:00 2001 From: Daniel Raper Date: Sat, 30 Dec 2023 16:41:31 +0000 Subject: [PATCH] Added charge slot active sensor --- README.md | 1 + custom_components/ohme/binary_sensor.py | 72 ++++++++++++++++++++++--- custom_components/ohme/sensor.py | 12 ++--- custom_components/ohme/utils.py | 38 ++++++++++--- 4 files changed, 102 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index dc6b23e..07f51a9 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ This integration exposes the following entities: * Car Connected - On when a car is plugged in * Car Charging - On when a car is connected and drawing power * Pending Approval - On when a car is connected and waiting for approval + * Charge Slot Active - On when a charge slot is in progress according to the Ohme-generated charge plan * Sensors (Charge power) - **These are only available during a charge session** * Power Draw (Watts) - Power draw of connected car * Current Draw (Amps) - Current draw of connected car diff --git a/custom_components/ohme/binary_sensor.py b/custom_components/ohme/binary_sensor.py index e41f90f..d8bad2b 100644 --- a/custom_components/ohme/binary_sensor.py +++ b/custom_components/ohme/binary_sensor.py @@ -6,10 +6,12 @@ BinarySensorEntity ) from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import generate_entity_id +from homeassistant.util.dt import (utcnow) from .const import DOMAIN, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, DATA_CLIENT from .coordinator import OhmeChargeSessionsCoordinator +from .utils import charge_graph_in_slot async def async_setup_entry( @@ -21,14 +23,15 @@ async def async_setup_entry( client = hass.data[DOMAIN][DATA_CLIENT] coordinator = hass.data[DOMAIN][DATA_COORDINATORS][COORDINATOR_CHARGESESSIONS] - sensors = [ConnectedSensor(coordinator, hass, client), - ChargingSensor(coordinator, hass, client), - PendingApprovalSensor(coordinator, hass, client)] + sensors = [ConnectedBinarySensor(coordinator, hass, client), + ChargingBinarySensor(coordinator, hass, client), + PendingApprovalBinarySensor(coordinator, hass, client), + CurrentSlotBinarySensor(coordinator, hass, client)] async_add_entities(sensors, update_before_add=True) -class ConnectedSensor( +class ConnectedBinarySensor( CoordinatorEntity[OhmeChargeSessionsCoordinator], BinarySensorEntity): """Binary sensor for if car is plugged in.""" @@ -74,7 +77,7 @@ def is_on(self) -> bool: return self._state -class ChargingSensor( +class ChargingBinarySensor( CoordinatorEntity[OhmeChargeSessionsCoordinator], BinarySensorEntity): """Binary sensor for if car is charging.""" @@ -121,7 +124,7 @@ def is_on(self) -> bool: return self._state -class PendingApprovalSensor( +class PendingApprovalBinarySensor( CoordinatorEntity[OhmeChargeSessionsCoordinator], BinarySensorEntity): """Binary sensor for if a charge is pending approval.""" @@ -165,3 +168,58 @@ def is_on(self) -> bool: self.coordinator.data["mode"] == "PENDING_APPROVAL") return self._state + + +class CurrentSlotBinarySensor( + CoordinatorEntity[OhmeChargeSessionsCoordinator], + BinarySensorEntity): + """Binary sensor for if we are currently in a smart charge slot.""" + + _attr_name = "Charge Slot Active" + + def __init__( + self, + coordinator: OhmeChargeSessionsCoordinator, + hass: HomeAssistant, + client): + super().__init__(coordinator=coordinator) + + self._attributes = {} + self._last_updated = None + self._state = False + self._client = client + + self.entity_id = generate_entity_id( + "binary_sensor.{}", "ohme_slot_active", hass=hass) + + self._attr_device_info = hass.data[DOMAIN][DATA_CLIENT].get_device_info( + ) + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:calendar-check" + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return self._client.get_unique_id("ohme_slot_active") + + @property + def is_on(self) -> bool: + return self._state + + @callback + def _handle_coordinator_update(self) -> None: + """Are we in a charge slot? This is a bit slow so we only update on coordinator data update.""" + if self.coordinator.data is None: + self._state = None + elif self.coordinator.data["mode"] == "DISCONNECTED": + self._state = False + else: + self._state = charge_graph_in_slot( + self.coordinator.data['startTime'], self.coordinator.data['chargeGraph']['points']) + + self._last_updated = utcnow() + + self.async_write_ha_state() diff --git a/custom_components/ohme/sensor.py b/custom_components/ohme/sensor.py index 633baa3..dd64c3b 100644 --- a/custom_components/ohme/sensor.py +++ b/custom_components/ohme/sensor.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entity import generate_entity_id from homeassistant.util.dt import (utcnow) from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, COORDINATOR_STATISTICS, COORDINATOR_ADVANCED -from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator +from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator, OhmeAdvancedSettingsCoordinator from .utils import charge_graph_next_slot @@ -165,7 +165,7 @@ def native_value(self): return None -class CTSensor(CoordinatorEntity[OhmeChargeSessionsCoordinator], SensorEntity): +class CTSensor(CoordinatorEntity[OhmeAdvancedSettingsCoordinator], SensorEntity): """Sensor for car power draw.""" _attr_name = "CT Reading" _attr_device_class = SensorDeviceClass.CURRENT @@ -173,7 +173,7 @@ class CTSensor(CoordinatorEntity[OhmeChargeSessionsCoordinator], SensorEntity): def __init__( self, - coordinator: OhmeChargeSessionsCoordinator, + coordinator: OhmeAdvancedSettingsCoordinator, hass: HomeAssistant, client): super().__init__(coordinator=coordinator) @@ -215,7 +215,7 @@ class EnergyUsageSensor(CoordinatorEntity[OhmeStatisticsCoordinator], SensorEnti def __init__( self, - coordinator: OhmeChargeSessionsCoordinator, + coordinator: OhmeStatisticsCoordinator, hass: HomeAssistant, client): super().__init__(coordinator=coordinator) @@ -250,7 +250,7 @@ def native_value(self): return None -class NextSlotStartSensor(CoordinatorEntity[OhmeStatisticsCoordinator], SensorEntity): +class NextSlotStartSensor(CoordinatorEntity[OhmeChargeSessionsCoordinator], SensorEntity): """Sensor for next smart charge slot start time.""" _attr_name = "Next Charge Slot Start" _attr_device_class = SensorDeviceClass.TIMESTAMP @@ -302,7 +302,7 @@ def _handle_coordinator_update(self) -> None: self.async_write_ha_state() -class NextSlotEndSensor(CoordinatorEntity[OhmeStatisticsCoordinator], SensorEntity): +class NextSlotEndSensor(CoordinatorEntity[OhmeChargeSessionsCoordinator], SensorEntity): """Sensor for next smart charge slot end time.""" _attr_name = "Next Charge Slot End" _attr_device_class = SensorDeviceClass.TIMESTAMP diff --git a/custom_components/ohme/utils.py b/custom_components/ohme/utils.py index 7b7b21b..06eed23 100644 --- a/custom_components/ohme/utils.py +++ b/custom_components/ohme/utils.py @@ -3,14 +3,17 @@ import pytz -def charge_graph_next_slot(charge_start, points): - """Get the next charge slot from a list of graph points.""" - # Get start and current timestamp in seconds +def _format_charge_graph(charge_start, points): + """Convert relative time in points array to real timestamp (s).""" + charge_start = round(charge_start / 1000) - now = int(time()) + return [{"t": x["x"] + charge_start, "y": x["y"]} for x in points] + - # Replace relative timestamp (seconds) with real timestamp - data = [{"t": x["x"] + charge_start, "y": x["y"]} for x in points] +def charge_graph_next_slot(charge_start, points): + """Get the next charge slot start/end times from a list of graph points.""" + now = int(time()) + data = _format_charge_graph(charge_start, points) # Filter to points from now onwards data = [x for x in data if x["t"] > now] @@ -32,9 +35,10 @@ def charge_graph_next_slot(charge_start, points): if delta > 10 and not start_ts: # 1s added here as it otherwise often rounds down to xx:59:59 start_ts = data[idx]["t"] + 1 - elif start_ts and delta == 0: # If we have seen a start and see a delta of 0, this is the end + + # Take the first delta of 0 as the end + if delta == 0 and not end_ts: end_ts = data[idx]["t"] + 1 - break # These need to be presented with tzinfo or Home Assistant will reject them return { @@ -43,6 +47,24 @@ def charge_graph_next_slot(charge_start, points): } +def charge_graph_in_slot(charge_start, points): + """Are we currently in a charge slot?""" + now = int(time()) + data = _format_charge_graph(charge_start, points) + + # Loop through every value, skipping the last + for idx in range(0, len(data) - 1): + # This is our current point + if data[idx]["t"] < now and data[idx + 1]["t"] > now: + # If the delta line we are on is steeper than 10, + # we are in a charge slot. + if data[idx + 1]["y"] - data[idx]["y"] > 10: + return True + break + + return False + + def time_next_occurs(hour, minute): """Find when this time next occurs.""" current = datetime.now()