diff --git a/README.md b/README.md index 14960fa..d3b05c9 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,20 @@ This has only be tested with an Ohme Home Pro and does not currently support soc It's still very early in development but I plan to add more sensors and support for pausing/resuming charge. +## Entities +This integration exposes the following entities: + +* Binary Sensors + * Car Connected - On when a car is plugged in + * Car Charging - On when a car is connected and drawing power +* Sensors + * Power Draw (Watts) - Power draw of connected car + * Accumulative Energy Usage (kWh) - Total energy used by the charger + * Next Smart Charge Slot - The next time your car will start charging according to the Ohme-generated charge plan +* Switches - These are only functional when a car is connected + * Max Charge - Forces the connected car to charge regardless of set schedule + * Pause Charge - Pauses an ongoing charge + ## Installation ### HACS diff --git a/custom_components/ohme/__init__.py b/custom_components/ohme/__init__.py index 9b4899b..798ba8e 100644 --- a/custom_components/ohme/__init__.py +++ b/custom_components/ohme/__init__.py @@ -1,6 +1,6 @@ from homeassistant import core from .const import * -from .client import OhmeApiClient +from .api_client import OhmeApiClient from .coordinator import OhmeUpdateCoordinator, OhmeStatisticsUpdateCoordinator @@ -30,11 +30,12 @@ async def async_setup_entry(hass, entry): return False await async_setup_dependencies(hass, config) - + hass.data[DOMAIN][DATA_COORDINATOR] = OhmeUpdateCoordinator(hass=hass) await hass.data[DOMAIN][DATA_COORDINATOR].async_config_entry_first_refresh() - hass.data[DOMAIN][DATA_STATISTICS_COORDINATOR] = OhmeStatisticsUpdateCoordinator(hass=hass) + hass.data[DOMAIN][DATA_STATISTICS_COORDINATOR] = OhmeStatisticsUpdateCoordinator( + hass=hass) await hass.data[DOMAIN][DATA_STATISTICS_COORDINATOR].async_config_entry_first_refresh() # Create tasks for each entity type @@ -50,6 +51,7 @@ async def async_setup_entry(hass, entry): return True + async def async_unload_entry(hass, entry): """Unload a config entry.""" diff --git a/custom_components/ohme/client/__init__.py b/custom_components/ohme/api_client.py similarity index 97% rename from custom_components/ohme/client/__init__.py rename to custom_components/ohme/api_client.py index 24d1b30..6f8c735 100644 --- a/custom_components/ohme/client/__init__.py +++ b/custom_components/ohme/api_client.py @@ -1,10 +1,8 @@ import aiohttp -import asyncio import logging -import json from datetime import datetime, timedelta from homeassistant.helpers.entity import DeviceInfo -from ..const import DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -100,12 +98,12 @@ async def async_resume_charge(self): """Resume a paused charge""" result = await self._post_request(f"https://api.ohme.io/v1/chargeSessions/{self._serial}/resume", skip_json=True) return bool(result) - + async def async_max_charge(self): """Enable max charge""" result = await self._put_request(f"https://api.ohme.io/v1/chargeSessions/{self._serial}/rule?maxCharge=true") return bool(result) - + async def async_stop_max_charge(self): """Stop max charge. This is more complicated than starting one as we need to give more parameters.""" @@ -116,7 +114,7 @@ async def async_get_charge_sessions(self, is_retry=False): """Try to fetch charge sessions endpoint. If we get a non 200 response, refresh auth token and try again""" resp = await self._get_request('https://api.ohme.io/v1/chargeSessions') - + if not resp: return False @@ -139,7 +137,7 @@ async def async_update_device_info(self, is_retry=False): sw_version=device['firmwareVersionLabel'], serial_number=device['id'] ) - + self._user_id = resp['user']['id'] self._serial = device['id'] self._device_info = info @@ -150,7 +148,8 @@ def _last_second_of_month_timestamp(self): """Get the last second of this month.""" dt = datetime.today() dt = dt.replace(day=1) + timedelta(days=32) - dt = dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - timedelta(seconds=1) + dt = dt.replace(day=1, hour=0, minute=0, second=0, + microsecond=0) - timedelta(seconds=1) return int(dt.timestamp()*1e3) async def async_get_charge_statistics(self): @@ -168,4 +167,3 @@ def get_device_info(self): def get_unique_id(self, name): return f"ohme_{self._serial}_{name}" - diff --git a/custom_components/ohme/binary_sensor.py b/custom_components/ohme/binary_sensor.py index 758667d..71f26fb 100644 --- a/custom_components/ohme/binary_sensor.py +++ b/custom_components/ohme/binary_sensor.py @@ -32,7 +32,7 @@ class ConnectedSensor( BinarySensorEntity): """Binary sensor for if car is plugged in.""" - _attr_name = "Ohme Car Connected" + _attr_name = "Car Connected" _attr_device_class = BinarySensorDeviceClass.PLUG def __init__( @@ -78,7 +78,7 @@ class ChargingSensor( BinarySensorEntity): """Binary sensor for if car is charging.""" - _attr_name = "Ohme Car Charging" + _attr_name = "Car Charging" _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING def __init__( diff --git a/custom_components/ohme/config_flow.py b/custom_components/ohme/config_flow.py index 3ef583e..7d49c30 100644 --- a/custom_components/ohme/config_flow.py +++ b/custom_components/ohme/config_flow.py @@ -1,7 +1,7 @@ import voluptuous as vol from homeassistant.config_entries import (ConfigFlow, OptionsFlow) from .const import DOMAIN -from .client import OhmeApiClient +from .api_client import OhmeApiClient class OhmeConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/custom_components/ohme/sensor.py b/custom_components/ohme/sensor.py index c180029..6f52f6c 100644 --- a/custom_components/ohme/sensor.py +++ b/custom_components/ohme/sensor.py @@ -7,10 +7,12 @@ ) from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.const import UnitOfPower, UnitOfEnergy -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_CLIENT, DATA_COORDINATOR, DATA_STATISTICS_COORDINATOR from .coordinator import OhmeUpdateCoordinator, OhmeStatisticsUpdateCoordinator +from .utils import charge_graph_next_slot async def async_setup_entry( @@ -23,14 +25,15 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][DATA_COORDINATOR] stats_coordinator = hass.data[DOMAIN][DATA_STATISTICS_COORDINATOR] - sensors = [PowerDrawSensor(coordinator, hass, client), EnergyUsageSensor(stats_coordinator, hass, client)] + sensors = [PowerDrawSensor(coordinator, hass, client), EnergyUsageSensor( + stats_coordinator, hass, client), NextSlotSensor(coordinator, hass, client)] async_add_entities(sensors, update_before_add=True) class PowerDrawSensor(CoordinatorEntity[OhmeUpdateCoordinator], SensorEntity): """Sensor for car power draw.""" - _attr_name = "Ohme Power Draw" + _attr_name = "Current Power Draw" _attr_native_unit_of_measurement = UnitOfPower.WATT _attr_device_class = SensorDeviceClass.POWER @@ -49,7 +52,8 @@ def __init__( self.entity_id = generate_entity_id( "sensor.{}", "ohme_power_draw", hass=hass) - self._attr_device_info = hass.data[DOMAIN][DATA_CLIENT].get_device_info() + self._attr_device_info = hass.data[DOMAIN][DATA_CLIENT].get_device_info( + ) @property def unique_id(self) -> str: @@ -71,7 +75,7 @@ def native_value(self): class EnergyUsageSensor(CoordinatorEntity[OhmeStatisticsUpdateCoordinator], SensorEntity): """Sensor for total energy usage.""" - _attr_name = "Ohme Accumulative Energy Usage" + _attr_name = "Accumulative Energy Usage" _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR _attr_device_class = SensorDeviceClass.ENERGY @@ -90,7 +94,8 @@ def __init__( self.entity_id = generate_entity_id( "sensor.{}", "ohme_accumulative_energy", hass=hass) - self._attr_device_info = hass.data[DOMAIN][DATA_CLIENT].get_device_info() + self._attr_device_info = hass.data[DOMAIN][DATA_CLIENT].get_device_info( + ) @property def unique_id(self) -> str: @@ -109,3 +114,55 @@ def native_value(self): return self.coordinator.data['energyChargedTotalWh'] / 1000 return None + + +class NextSlotSensor(CoordinatorEntity[OhmeStatisticsUpdateCoordinator], SensorEntity): + """Sensor for next smart charge slot.""" + _attr_name = "Next Smart Charge Slot" + _attr_device_class = SensorDeviceClass.TIMESTAMP + + def __init__( + self, + coordinator: OhmeUpdateCoordinator, + hass: HomeAssistant, + client): + super().__init__(coordinator=coordinator) + + self._state = None + self._attributes = {} + self._last_updated = None + self._client = client + + self.entity_id = generate_entity_id( + "sensor.{}", "ohme_next_slot", hass=hass) + + self._attr_device_info = hass.data[DOMAIN][DATA_CLIENT].get_device_info( + ) + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return self._client.get_unique_id("next_slot") + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:clock-star-four-points-outline" + + @property + def native_value(self): + """Return pre-calculated state.""" + return self._state + + @callback + def _handle_coordinator_update(self) -> None: + """Calculate next timeslot. This is a bit slow so we only update on coordinator data update.""" + if self.coordinator.data is None: + self._state = None + else: + self._state = charge_graph_next_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/switch.py b/custom_components/ohme/switch.py index 3533f4b..20a87bf 100644 --- a/custom_components/ohme/switch.py +++ b/custom_components/ohme/switch.py @@ -34,7 +34,7 @@ async def async_setup_entry( class OhmePauseCharge(CoordinatorEntity[OhmeUpdateCoordinator], SwitchEntity): """Switch for pausing a charge.""" - _attr_name = "Ohme Pause Charge" + _attr_name = "Pause Charge" def __init__(self, coordinator, hass: HomeAssistant, client): super().__init__(coordinator=coordinator) @@ -84,13 +84,14 @@ async def async_turn_on(self): async def async_turn_off(self): """Turn off the switch.""" await self._client.async_resume_charge() - + await asyncio.sleep(1) await self.coordinator.async_refresh() + class OhmeMaxCharge(CoordinatorEntity[OhmeUpdateCoordinator], SwitchEntity): """Switch for pausing a charge.""" - _attr_name = "Ohme Max Charge" + _attr_name = "Max Charge" def __init__(self, coordinator, hass: HomeAssistant, client): super().__init__(coordinator=coordinator) @@ -122,7 +123,8 @@ def _handle_coordinator_update(self) -> None: if self.coordinator.data is None: self._attr_is_on = False else: - self._attr_is_on = bool(self.coordinator.data["mode"] == "MAX_CHARGE") + self._attr_is_on = bool( + self.coordinator.data["mode"] == "MAX_CHARGE") self._last_updated = utcnow() diff --git a/custom_components/ohme/utils.py b/custom_components/ohme/utils.py new file mode 100644 index 0000000..28dd6d6 --- /dev/null +++ b/custom_components/ohme/utils.py @@ -0,0 +1,36 @@ +from time import time +from datetime import datetime +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 + charge_start = round(charge_start / 1000) + now = int(time()) + + # Replace relative timestamp (seconds) with real timestamp + data = [{"t": x["x"] + charge_start, "y": x["y"]} for x in points] + + # Filter to points from now onwards + data = [x for x in data if x["t"] > now] + + # Give up if we have less than 3 points + if len(data) < 3: + return None + + next_ts = None + + # Loop through every remaining value, skipping the last + for idx in range(0, len(data) - 1): + # Calculate the delta between this element and the next + delta = data[idx + 1]["y"] - data[idx]["y"] + + # If the next point has a Y delta of 10+, consider this the start of a slot + # This should be 0+ but I had some strange results in testing... revisit + if delta > 10: + next_ts = data[idx]["t"] + break + + # This needs to be presented with tzinfo or Home Assistant will reject it + return None if next_ts is None else datetime.utcfromtimestamp(next_ts).replace(tzinfo=pytz.utc)