Skip to content

Commit

Permalink
Add target time and percentage inputs (#25)
Browse files Browse the repository at this point in the history
* Moved max charge resume code into common api_client

* Refactor entity loading

* Switch housekeeping

* Added target time and percentage inputs
  • Loading branch information
dan-r authored Jan 1, 2024
1 parent 0c04e55 commit 616a93e
Show file tree
Hide file tree
Showing 6 changed files with 197 additions and 60 deletions.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ This integration exposes the following entities:
* 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**
* Sensors (Charge power) - **Only available during a charge session**
* Power Draw (Watts) - Power draw of connected car
* Current Draw (Amps) - Current draw of connected car
* Voltage (Volts) - Voltage reading
Expand All @@ -54,6 +54,9 @@ This integration exposes the following entities:
* Switches (Charge state) - **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
* Inputs - **Only available during a charge session**
* Number: Target Percentage - Change the target percentage of the ongoing charge
* Time: Target Time - Change the time target for the current charge
* Buttons
* Approve Charge - Approves a charge when 'Pending Approval' is on

Expand All @@ -67,6 +70,7 @@ The coordinators are listed with their refresh intervals below. Relevant coordin
* Buttons: Approve Charge
* Sensors: Power, current, voltage and next slot (start & end)
* Switches: Max charge, pause charge
* Inputs: Target time and target percentage
* OhmeAccountInfoCoordinator (1m refresh)
* Switches: Lock buttons, require approval and sleep when inactive
* OhmeAdvancedSettingsCoordinator (1m refresh)
Expand Down
17 changes: 5 additions & 12 deletions custom_components/ohme/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,11 @@ async def async_setup_entry(hass, entry):
hass.data[DOMAIN][DATA_COORDINATORS] = coordinators

# Create tasks for each entity type
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, "sensor")
)
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, "binary_sensor")
)
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, "switch")
)
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, "button")
)
entity_types = ["sensor", "binary_sensor", "switch", "button", "number", "time"]
for entity_type in entity_types:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, entity_type)
)

return True

Expand Down
41 changes: 37 additions & 4 deletions custom_components/ohme/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from datetime import datetime, timedelta
from homeassistant.helpers.entity import DeviceInfo
from .const import DOMAIN
from .utils import time_next_occurs

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -36,6 +37,9 @@ def __init__(self, email, password):
self._user_id = ""
self._serial = ""

# Cache the last rule to use when we disable max charge or change schedule
self._last_rule = {}

# Sessions
self._session = aiohttp.ClientSession(
base_url="https://api.ohme.io")
Expand Down Expand Up @@ -156,7 +160,7 @@ async def _get_request(self, url):
def ct_connected(self):
"""Is CT clamp connected."""
return self._ct_connected

def is_capable(self, capability):
"""Return whether or not this model has a given capability."""
return bool(self._capabilities[capability])
Expand Down Expand Up @@ -189,9 +193,33 @@ async def async_max_charge(self):
result = await self._put_request(f"/v1/chargeSessions/{self._serial}/rule?maxCharge=true")
return bool(result)

async def async_apply_charge_rule(self, max_price=False, target_ts=0, target_percent=100, pre_condition=False, pre_condition_length=0):
async def async_apply_charge_rule(self, max_price=None, target_time=None, target_percent=None, pre_condition=None, pre_condition_length=None):
"""Apply charge rule/stop max charge."""
# Check every property. If we've provided it, use that. If not, use the existing.
if max_price is None:
max_price = self._last_rule['settings'][0]['enabled'] if 'settings' in self._last_rule and len(
self._last_rule['settings']) > 1 else False

if target_percent is None:
target_percent = self._last_rule['targetPercent'] if 'targetPercent' in self._last_rule else 80

if pre_condition is None:
pre_condition = self._last_rule['preconditioningEnabled'] if 'preconditioningEnabled' in self._last_rule else False

if pre_condition_length is None:
pre_condition_length = self._last_rule[
'preconditionLengthMins'] if 'preconditionLengthMins' in self._last_rule else 30

if target_time is None:
# Default to 9am
target_time = self._last_rule['targetTime'] if 'targetTime' in self._last_rule else 32400
target_time = (target_time // 3600,
(target_time % 3600) // 60)

target_ts = int(time_next_occurs(
target_time[0], target_time[1]).timestamp() * 1000)

# Convert these to string form
max_price = 'true' if max_price else 'false'
pre_condition = 'true' if pre_condition else 'false'

Expand All @@ -209,8 +237,13 @@ 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('/v1/chargeSessions')
resp = resp[0]

return resp[0]
# Cache the current rule if we are given it
if resp["mode"] == "SMART_CHARGE" and 'appliedRule' in resp:
self._last_rule = resp["appliedRule"]

return resp

async def async_get_account_info(self):
resp = await self._get_request('/v1/users/me/account')
Expand Down Expand Up @@ -249,7 +282,7 @@ async def async_get_charge_statistics(self):
async def async_get_ct_reading(self):
"""Get CT clamp reading."""
resp = await self._get_request(f"/v1/chargeDevices/{self._serial}/advancedSettings")

# If we ever get a reading above 0, assume CT connected
if resp['clampAmps'] and resp['clampAmps'] > 0:
self._ct_connected = True
Expand Down
74 changes: 74 additions & 0 deletions custom_components/ohme/number.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from __future__ import annotations
import asyncio
from homeassistant.components.number import NumberEntity, NumberDeviceClass
from homeassistant.helpers.entity import generate_entity_id
from homeassistant.core import callback, HomeAssistant
from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, COORDINATOR_ACCOUNTINFO


async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
async_add_entities
):
"""Setup switches and configure coordinator."""
coordinators = hass.data[DOMAIN][DATA_COORDINATORS]

coordinator = coordinators[COORDINATOR_CHARGESESSIONS]
client = hass.data[DOMAIN][DATA_CLIENT]

numbers = [TargetPercentNumber(coordinator, hass, client)]

async_add_entities(numbers, update_before_add=True)


class TargetPercentNumber(NumberEntity):
"""Target percentage sensor."""
_attr_name = "Target Percentage"
_attr_device_class = NumberDeviceClass.BATTERY
_attr_suggested_display_precision = 0

def __init__(self, coordinator, hass: HomeAssistant, client):
self.coordinator = coordinator

self._client = client

self._state = 0
self._last_updated = None
self._attributes = {}

self.entity_id = generate_entity_id(
"number.{}", "ohme_target_percent", hass=hass)

self._attr_device_info = client.get_device_info()

@property
def unique_id(self):
"""The unique ID of the switch."""
return self._client.get_unique_id("target_percent")

async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
await self._client.async_apply_charge_rule(target_percent=int(value))

await asyncio.sleep(1)
await self.coordinator.async_refresh()

@property
def icon(self):
"""Icon of the sensor."""
return "mdi:battery-heart"

@property
def native_value(self):
"""Get value from data returned from API by coordinator"""
if self.coordinator.data and self.coordinator.data['appliedRule']:
target = round(
self.coordinator.data['appliedRule']['targetPercent'])

if target == 0:
return self._state

self._state = target
return self._state
return None
45 changes: 2 additions & 43 deletions custom_components/ohme/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, COORDINATOR_ACCOUNTINFO
from .coordinator import OhmeChargeSessionsCoordinator, OhmeAccountInfoCoordinator
from .utils import time_next_occurs

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -122,9 +121,6 @@ def __init__(self, coordinator, hass: HomeAssistant, client):
self._last_updated = None
self._attributes = {}

# Cache the last rule to use when we disable max charge
self._last_rule = {}

self.entity_id = generate_entity_id(
"switch.{}", "ohme_max_charge", hass=hass)

Expand All @@ -149,10 +145,6 @@ def _handle_coordinator_update(self) -> None:
self._attr_is_on = bool(
self.coordinator.data["mode"] == "MAX_CHARGE")

# Cache the current rule if we are given it
if self.coordinator.data["mode"] == "SMART_CHARGE" and 'appliedRule' in self.coordinator.data:
self._last_rule = self.coordinator.data["appliedRule"]

self._last_updated = utcnow()

self.async_write_ha_state()
Expand All @@ -168,41 +160,8 @@ async def async_turn_on(self):

async def async_turn_off(self):
"""Stop max charging.
We have to provide a full rule to disable max charge, so we try to get as much as possible
from the cached rule, and assume sane defaults if that isn't possible."""

max_price = False
target_ts = 0
target_percent = 80
pre_condition = False,
pre_condition_length = 0

if self._last_rule and 'targetTime' in self._last_rule:
# Convert rule time (seconds from 00:00 to time) to hh:mm
# and find when it next occurs.
next_dt = time_next_occurs(
self._last_rule['targetTime'] // 3600,
(self._last_rule['targetTime'] % 3600) // 60
)
target_ts = int(next_dt.timestamp() * 1000)
else:
next_dt = time_next_occurs(9, 0)
target_ts = int(next_dt.timestamp() * 1000)

if self._last_rule:
max_price = self._last_rule['settings'][0]['enabled'] if 'settings' in self._last_rule and len(
self._last_rule['settings']) > 1 else max_price
target_percent = self._last_rule['targetPercent'] if 'targetPercent' in self._last_rule else target_percent
pre_condition = self._last_rule['preconditioningEnabled'] if 'preconditioningEnabled' in self._last_rule else pre_condition
pre_condition_length = self._last_rule['preconditionLengthMins'] if 'preconditionLengthMins' in self._last_rule else pre_condition_length

await self._client.async_apply_charge_rule(
max_price=max_price,
target_ts=target_ts,
target_percent=target_percent,
pre_condition=pre_condition,
pre_condition_length=pre_condition_length
)
We are not changing anything, just applying the last rule. No need to supply anything."""
await self._client.async_apply_charge_rule()

await asyncio.sleep(1)
await self.coordinator.async_refresh()
Expand Down
74 changes: 74 additions & 0 deletions custom_components/ohme/time.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from __future__ import annotations
import asyncio
import logging
from homeassistant.components.time import TimeEntity
from homeassistant.helpers.entity import generate_entity_id
from homeassistant.core import callback, HomeAssistant
from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, COORDINATOR_ACCOUNTINFO
from datetime import time as dt_time

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
async_add_entities
):
"""Setup switches and configure coordinator."""
coordinators = hass.data[DOMAIN][DATA_COORDINATORS]

coordinator = coordinators[COORDINATOR_CHARGESESSIONS]
client = hass.data[DOMAIN][DATA_CLIENT]

numbers = [TargetTime(coordinator, hass, client)]

async_add_entities(numbers, update_before_add=True)


class TargetTime(TimeEntity):
"""Target time sensor."""
_attr_name = "Target Time"

def __init__(self, coordinator, hass: HomeAssistant, client):
self.coordinator = coordinator

self._client = client

self._state = 0
self._last_updated = None
self._attributes = {}

self.entity_id = generate_entity_id(
"number.{}", "ohme_target_time", hass=hass)

self._attr_device_info = client.get_device_info()

@property
def unique_id(self):
"""The unique ID of the switch."""
return self._client.get_unique_id("target_time")

async def async_set_value(self, value: dt_time) -> None:
"""Update the current value."""
await self._client.async_apply_charge_rule(target_time=(int(value.hour), int(value.minute)))

await asyncio.sleep(1)
await self.coordinator.async_refresh()

@property
def icon(self):
"""Icon of the sensor."""
return "mdi:alarm-check"

@property
def native_value(self):
"""Get value from data returned from API by coordinator"""
if self.coordinator.data and self.coordinator.data['appliedRule']:
target = self.coordinator.data['appliedRule']['targetTime']
return dt_time(
hour=target // 3600,
minute=(target % 3600) // 60,
second=0
)
return None

0 comments on commit 616a93e

Please sign in to comment.