Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added 'Next Charge Slot' sensor #4

Merged
merged 7 commits into from
Dec 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Loading