Skip to content

Commit

Permalink
Allow modifying charge schedules (#29)
Browse files Browse the repository at this point in the history
* Add ChargeSchedulesCoordinator and schedule update

* Add schedule change function

* Shorten ChargingBinarySensor cooldown

* Updated version
  • Loading branch information
dan-r authored Jan 2, 2024
1 parent 8956d41 commit 799c9f7
Show file tree
Hide file tree
Showing 8 changed files with 100 additions and 36 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +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
* Inputs - **If in a charge session, this will change the active charge. If disconnected, this will change your first schedule.**
* Number: Target Percentage - Change the target battery percentage
* Time: Target Time - Change the target time
* Buttons
* Approve Charge - Approves a charge when 'Pending Approval' is on

Expand Down
5 changes: 3 additions & 2 deletions custom_components/ohme/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from homeassistant import core
from .const import *
from .api_client import OhmeApiClient
from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator, OhmeAccountInfoCoordinator, OhmeAdvancedSettingsCoordinator
from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator, OhmeAccountInfoCoordinator, OhmeAdvancedSettingsCoordinator, OhmeChargeSchedulesCoordinator


async def async_setup(hass: core.HomeAssistant, config: dict) -> bool:
Expand Down Expand Up @@ -35,7 +35,8 @@ async def async_setup_entry(hass, entry):
OhmeChargeSessionsCoordinator(hass=hass), # COORDINATOR_CHARGESESSIONS
OhmeAccountInfoCoordinator(hass=hass), # COORDINATOR_ACCOUNTINFO
OhmeStatisticsCoordinator(hass=hass), # COORDINATOR_STATISTICS
OhmeAdvancedSettingsCoordinator(hass=hass) # COORDINATOR_ADVANCED
OhmeAdvancedSettingsCoordinator(hass=hass), # COORDINATOR_ADVANCED
OhmeChargeSchedulesCoordinator(hass=hass) # COORDINATOR_SCHEDULES
]

for coordinator in coordinators:
Expand Down
25 changes: 24 additions & 1 deletion custom_components/ohme/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ async def async_max_charge(self):
return bool(result)

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."""
"""Apply rule to ongoing charge/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(
Expand Down Expand Up @@ -226,6 +226,29 @@ async def async_apply_charge_rule(self, max_price=None, target_time=None, target

result = await self._put_request(f"/v1/chargeSessions/{self._serial}/rule?enableMaxPrice={max_price}&targetTs={target_ts}&enablePreconditioning={pre_condition}&toPercent={target_percent}&preconditionLengthMins={pre_condition_length}")
return bool(result)

async def async_get_schedule(self):
"""Get the first schedule."""
schedules = await self._get_request("/v1/chargeRules")

return schedules[0] if len(schedules) > 0 else None

async def async_update_schedule(self, target_percent=None, target_time=None):
"""Update the first listed schedule."""
rule = await self.async_get_schedule()

# Account for user having no rules
if not rule:
return None

# Update percent and time if provided
if target_percent is not None:
rule['targetPercent'] = target_percent
if target_time is not None:
rule['targetTime'] = (target_time[0] * 3600) + (target_time[1] * 60)

await self._put_request(f"/v1/chargeRules/{rule['id']}", data=rule)
return True

async def async_set_configuration_value(self, values):
"""Set a configuration value or values."""
Expand Down
4 changes: 2 additions & 2 deletions custom_components/ohme/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,9 @@ def _calculate_state(self) -> bool:
@callback
def _handle_coordinator_update(self) -> None:
"""Update data."""
# Don't accept updates if 20s hasnt passed
# Don't accept updates if 5s hasnt passed
# State calculations use deltas that may be unreliable to check if requests are too often
if self._last_updated and (utcnow().timestamp() - self._last_updated.timestamp() < 20):
if self._last_updated and (utcnow().timestamp() - self._last_updated.timestamp() < 5):
_LOGGER.debug("ChargingBinarySensor: State update too soon - suppressing")
return

Expand Down
5 changes: 3 additions & 2 deletions custom_components/ohme/const.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""Component constants"""
DOMAIN = "ohme"
USER_AGENT = "dan-r-homeassistant-ohme"
INTEGRATION_VERSION = "0.2.7"
INTEGRATION_VERSION = "0.2.8"

DATA_CLIENT = "client"
DATA_COORDINATORS = "coordinators"
COORDINATOR_CHARGESESSIONS = 0
COORDINATOR_ACCOUNTINFO = 1
COORDINATOR_STATISTICS = 2
COORDINATOR_ADVANCED = 3
COORDINATOR_ADVANCED = 3
COORDINATOR_SCHEDULES = 4
22 changes: 22 additions & 0 deletions custom_components/ohme/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,25 @@ async def _async_update_data(self):

except BaseException:
raise UpdateFailed("Error communicating with API")

class OhmeChargeSchedulesCoordinator(DataUpdateCoordinator):
"""Coordinator to pull charge schedules."""

def __init__(self, hass):
"""Initialise coordinator."""
super().__init__(
hass,
_LOGGER,
name="Ohme Charge Schedules",
update_interval=timedelta(minutes=10),
)
self._client = hass.data[DOMAIN][DATA_CLIENT]

async def _async_update_data(self):
"""Fetch data from API endpoint."""
try:
return await self._client.async_get_schedule()

except BaseException:
raise UpdateFailed("Error communicating with API")

36 changes: 20 additions & 16 deletions custom_components/ohme/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
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
from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, COORDINATOR_SCHEDULES


async def async_setup_entry(
Expand All @@ -14,10 +14,10 @@ async def async_setup_entry(
"""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)]
numbers = [TargetPercentNumber(
coordinators[COORDINATOR_CHARGESESSIONS], coordinators[COORDINATOR_SCHEDULES], hass, client)]

async_add_entities(numbers, update_before_add=True)

Expand All @@ -28,12 +28,13 @@ class TargetPercentNumber(NumberEntity):
_attr_device_class = NumberDeviceClass.BATTERY
_attr_suggested_display_precision = 0

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

self._client = client

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

Expand All @@ -49,10 +50,15 @@ def unique_id(self):

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()
# If disconnected, update top rule. If not, apply rule to current session
if self.coordinator.data and self.coordinator.data['mode'] == "DISCONNECTED":
await self._client.async_update_schedule(target_percent=int(value))
await asyncio.sleep(1)
await self.coordinator_schedules.async_refresh()
else:
await self._client.async_apply_charge_rule(target_percent=int(value))
await asyncio.sleep(1)
await self.coordinator.async_refresh()

@property
def icon(self):
Expand All @@ -62,13 +68,11 @@ def icon(self):
@property
def native_value(self):
"""Get value from data returned from API by coordinator"""
if self.coordinator.data and self.coordinator.data['appliedRule']:
if self.coordinator.data and self.coordinator.data['appliedRule'] and self.coordinator.data['mode'] != "PENDING_APPROVAL" and self.coordinator.data['mode'] != "DISCONNECTED":
target = round(
self.coordinator.data['appliedRule']['targetPercent'])
elif self.coordinator_schedules.data:
target = round(self.coordinator_schedules.data['targetPercent'])

if target == 0:
return self._state

self._state = target
return self._state
return None
self._state = target if target > 0 else None
return self._state
33 changes: 23 additions & 10 deletions custom_components/ohme/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
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 .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, COORDINATOR_SCHEDULES
from datetime import time as dt_time

_LOGGER = logging.getLogger(__name__)
Expand All @@ -18,10 +18,10 @@ async def async_setup_entry(
"""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)]
numbers = [TargetTime(coordinators[COORDINATOR_CHARGESESSIONS],
coordinators[COORDINATOR_SCHEDULES], hass, client)]

async_add_entities(numbers, update_before_add=True)

Expand All @@ -30,8 +30,9 @@ class TargetTime(TimeEntity):
"""Target time sensor."""
_attr_name = "Target Time"

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

self._client = client

Expand All @@ -51,10 +52,17 @@ def unique_id(self):

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()
# If disconnected, update top rule. If not, apply rule to current session
if self.coordinator.data and self.coordinator.data['mode'] == "DISCONNECTED":
await self._client.async_update_schedule(target_time=(int(value.hour), int(value.minute)))
await asyncio.sleep(1)
await self.coordinator_schedules.async_refresh()
else:
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):
Expand All @@ -64,9 +72,14 @@ def icon(self):
@property
def native_value(self):
"""Get value from data returned from API by coordinator"""
# Make sure we're not pending approval, as this sets the target time to now
if self.coordinator.data and self.coordinator.data['appliedRule'] and self.coordinator.data['mode'] != "PENDING_APPROVAL":
# If we are not pending approval or disconnected, return in progress charge rule
target = None
if self.coordinator.data and self.coordinator.data['appliedRule'] and self.coordinator.data['mode'] != "PENDING_APPROVAL" and self.coordinator.data['mode'] != "DISCONNECTED":
target = self.coordinator.data['appliedRule']['targetTime']
elif self.coordinator_schedules.data:
target = self.coordinator_schedules.data['targetTime']

if target:
self._state = dt_time(
hour=target // 3600,
minute=(target % 3600) // 60,
Expand Down

0 comments on commit 799c9f7

Please sign in to comment.