Skip to content

Commit

Permalink
Added 'Next Charge Slot' sensor (#4)
Browse files Browse the repository at this point in the history
* Code formatting

* Documentation updates with entities

* Added next slot sensor

* Move graph timeslot calculation

* Remove Ohme prefix from attribute names

* Add Next Smart Charge Slot to README

* Tidy up and move api_client
  • Loading branch information
dan-r committed Dec 27, 2023
1 parent a5bd807 commit 786d13e
Show file tree
Hide file tree
Showing 8 changed files with 134 additions and 25 deletions.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions custom_components/ohme/__init__.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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
Expand All @@ -50,6 +51,7 @@ async def async_setup_entry(hass, entry):

return True


async def async_unload_entry(hass, entry):
"""Unload a config entry."""

Expand Down
Original file line number Diff line number Diff line change
@@ -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__)

Expand Down Expand Up @@ -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."""
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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):
Expand All @@ -168,4 +167,3 @@ def get_device_info(self):

def get_unique_id(self, name):
return f"ohme_{self._serial}_{name}"

4 changes: 2 additions & 2 deletions custom_components/ohme/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand Down Expand Up @@ -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__(
Expand Down
2 changes: 1 addition & 1 deletion custom_components/ohme/config_flow.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
69 changes: 63 additions & 6 deletions custom_components/ohme/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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

Expand All @@ -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:
Expand All @@ -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

Expand All @@ -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:
Expand All @@ -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()
10 changes: 6 additions & 4 deletions custom_components/ohme/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()

Expand Down
36 changes: 36 additions & 0 deletions custom_components/ohme/utils.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit 786d13e

Please sign in to comment.