Skip to content

Commit

Permalink
Added preconditioning support and 'never session specific' option (#43)
Browse files Browse the repository at this point in the history
* Properly handle credentials updates

* Spring clean setup routine

* Add never session specific option

* Read default state for options flow checkbox

* Added preconditioning input

* Bigger version bump

* Wording changes

* Add FUNDING.yml
  • Loading branch information
dan-r committed Jan 18, 2024
1 parent dbac414 commit dff1362
Show file tree
Hide file tree
Showing 10 changed files with 188 additions and 36 deletions.
1 change: 1 addition & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
github: dan-r
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,19 @@ This integration exposes the following entities:
* Max Charge - Forces the connected car to charge regardless of set schedule
* Pause Charge - Pauses an ongoing charge
* Inputs - **If in a charge session, these change the active charge. If disconnected, they change your first schedule.**
* Number: Target Percentage - Change the target battery percentage
* Time: Target Time - Change the target time
* Number
* Target Percentage - Change the target battery percentage
* Preconditioning - Change pre-conditioning time. 0 is off
* Time
* Target Time - Change the target time
* Buttons
* Approve Charge - Approves a charge when 'Pending Approval' is on

## Options
Some options can be set from the 'Configure' menu in Home Assistant:
* Never update an ongoing session - Override the default behaviour of the target time, percentage and preconditioning inputs and only ever update the schedule, not the current session. This was added as changing the current session can cause issues for customers on Intelligent Octopus Go.


## Coordinators
Updates are made to entity states by polling the Ohme API. This is handled by 'coordinators' defined to Home Assistant, which refresh at a set interval or when externally triggered.

Expand All @@ -74,7 +82,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 (If car connected)
* Inputs: Target time, target percentage and preconditioning (If car connected)
* OhmeAccountInfoCoordinator (1m refresh)
* Switches: Lock buttons, require approval and sleep when inactive
* OhmeAdvancedSettingsCoordinator (1m refresh)
Expand All @@ -83,4 +91,4 @@ The coordinators are listed with their refresh intervals below. Relevant coordin
* OhmeStatisticsCoordinator (30m refresh)
* Sensors: Accumulative energy usage
* OhmeChargeSchedulesCoordinator (10m refresh)
* Inputs: Target time and target percentage (If car disconnected)
* Inputs: Target time, target percentage and preconditioning (If car disconnected)
30 changes: 19 additions & 11 deletions custom_components/ohme/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,32 @@ async def async_setup(hass: core.HomeAssistant, config: dict) -> bool:
return True


async def async_setup_dependencies(hass, config):
async def async_setup_dependencies(hass, entry):
"""Instantiate client and refresh session"""
client = OhmeApiClient(config['email'], config['password'])
client = OhmeApiClient(entry.data['email'], entry.data['password'])
hass.data[DOMAIN][DATA_CLIENT] = client

hass.data[DOMAIN][DATA_OPTIONS] = entry.options

await client.async_create_session()
await client.async_update_device_info()


async def async_update_listener(hass, entry):
"""Handle options flow credentials update."""
# Re-instantiate the API client
await async_setup_dependencies(hass, entry)

# Refresh all coordinators for good measure
for coordinator in hass.data[DOMAIN][DATA_COORDINATORS]:
await coordinator.async_refresh()


async def async_setup_entry(hass, entry):
"""This is called from the config flow."""
hass.data.setdefault(DOMAIN, {})
config = dict(entry.data)

if entry.options:
config.update(entry.options)

if "email" not in config:
return False

await async_setup_dependencies(hass, config)
await async_setup_dependencies(hass, entry)

coordinators = [
OhmeChargeSessionsCoordinator(hass=hass), # COORDINATOR_CHARGESESSIONS
Expand Down Expand Up @@ -70,6 +75,8 @@ async def async_setup_entry(hass, entry):
hass.config_entries.async_forward_entry_setup(entry, entity_type)
)

entry.async_on_unload(entry.add_update_listener(async_update_listener))

return True


Expand All @@ -78,6 +85,7 @@ async def async_unload_entry(hass, entry):

return await hass.config_entries.async_unload_platforms(entry, ENTITY_TYPES)


async def async_migrate_entry(hass: core.HomeAssistant, config_entry) -> bool:
"""Migrate old entry."""
# Version number has gone backwards
Expand All @@ -92,7 +100,7 @@ async def async_migrate_entry(hass: core.HomeAssistant, config_entry) -> bool:

config_entry.version = CONFIG_VERSION
hass.config_entries.async_update_entry(config_entry, data=new_data)

_LOGGER.debug("Migration to version %s successful", config_entry.version)

return True
8 changes: 7 additions & 1 deletion custom_components/ohme/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ async def async_get_schedule(self):

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

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

Expand All @@ -247,6 +247,12 @@ async def async_update_schedule(self, target_percent=None, target_time=None):
if target_time is not None:
rule['targetTime'] = (target_time[0] * 3600) + (target_time[1] * 60)

# Update pre-conditioning if provided
if pre_condition is not None:
rule['preconditioningEnabled'] = pre_condition
if pre_condition_length is not None:
rule['preconditionLengthMins'] = pre_condition_length

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

Expand Down
40 changes: 30 additions & 10 deletions custom_components/ohme/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,36 @@ class OhmeOptionsFlow(OptionsFlow):
def __init__(self, entry) -> None:
self._config_entry = entry

async def async_step_init(self, info):
async def async_step_init(self, options):
errors = {}
if info is not None:
instance = OhmeApiClient(info['email'], info['password'])
if await instance.async_refresh_session() is None:
errors["base"] = "auth_error"
else:
# If form filled
if options is not None:
data = self._config_entry.data

# Update credentials
if 'email' in options and 'password' in options:
instance = OhmeApiClient(options['email'], options['password'])
if await instance.async_refresh_session() is None:
errors["base"] = "auth_error"
else:
data['email'] = options['email']
data['password'] = options['password']

# If we have no errors, update the data array
if len(errors) == 0:
# Don't store email and password in options
options.pop('email', None)
options.pop('password', None)

# Update data
self.hass.config_entries.async_update_entry(
self._config_entry, data=info
self._config_entry, data=data
)

# Update options
return self.async_create_entry(
title="",
data={}
data=options
)

return self.async_show_form(
Expand All @@ -64,8 +81,11 @@ async def async_step_init(self, info):
vol.Required(
"email", default=self._config_entry.data['email']
): str,
vol.Required(
vol.Optional(
"password"
): str
): str,
vol.Required(
"never_session_specific", default=self._config_entry.options.get("never_session_specific", False)
) : bool
}), errors=errors
)
4 changes: 3 additions & 1 deletion custom_components/ohme/const.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""Component constants"""
DOMAIN = "ohme"
USER_AGENT = "dan-r-homeassistant-ohme"
INTEGRATION_VERSION = "0.3.2"
INTEGRATION_VERSION = "0.4.0"
CONFIG_VERSION = 1
ENTITY_TYPES = ["sensor", "binary_sensor", "switch", "button", "number", "time"]

DATA_CLIENT = "client"
DATA_COORDINATORS = "coordinators"
DATA_OPTIONS = "options"

COORDINATOR_CHARGESESSIONS = 0
COORDINATOR_ACCOUNTINFO = 1
COORDINATOR_STATISTICS = 2
Expand Down
102 changes: 98 additions & 4 deletions custom_components/ohme/number.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from __future__ import annotations
import asyncio
from homeassistant.components.number import NumberEntity, NumberDeviceClass
from homeassistant.const import UnitOfTime
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_SCHEDULES
from .utils import session_in_progress


async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
Expand All @@ -17,6 +19,8 @@ async def async_setup_entry(
client = hass.data[DOMAIN][DATA_CLIENT]

numbers = [TargetPercentNumber(
coordinators[COORDINATOR_CHARGESESSIONS], coordinators[COORDINATOR_SCHEDULES], hass, client),
PreconditioningNumber(
coordinators[COORDINATOR_CHARGESESSIONS], coordinators[COORDINATOR_SCHEDULES], hass, client)]

async_add_entities(numbers, update_before_add=True)
Expand Down Expand Up @@ -56,7 +60,7 @@ async def async_added_to_hass(self) -> None:
self._handle_coordinator_update, None
)
)

@property
def unique_id(self):
"""The unique ID of the switch."""
Expand All @@ -65,7 +69,7 @@ def unique_id(self):
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
# If session in progress, update this session, if not update the first schedule
if session_in_progress(self.coordinator.data):
if session_in_progress(self.hass, self.coordinator.data):
await self._client.async_apply_session_rule(target_percent=int(value))
await asyncio.sleep(1)
await self.coordinator.async_refresh()
Expand All @@ -83,8 +87,9 @@ def icon(self):
def _handle_coordinator_update(self) -> None:
"""Get value from data returned from API by coordinator"""
# Set with the same logic as reading
if session_in_progress(self.coordinator.data):
target = round(self.coordinator.data['appliedRule']['targetPercent'])
if session_in_progress(self.hass, self.coordinator.data):
target = round(
self.coordinator.data['appliedRule']['targetPercent'])
elif self.coordinator_schedules.data:
target = round(self.coordinator_schedules.data['targetPercent'])

Expand All @@ -93,3 +98,92 @@ def _handle_coordinator_update(self) -> None:
@property
def native_value(self):
return self._state


class PreconditioningNumber(NumberEntity):
"""Preconditioning sensor."""
_attr_name = "Preconditioning"
_attr_device_class = NumberDeviceClass.DURATION
_attr_native_unit_of_measurement = UnitOfTime.MINUTES
_attr_native_min_value = 0
_attr_native_step = 5
_attr_native_max_value = 60

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

self._client = client

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

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

self._attr_device_info = client.get_device_info()

async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.async_add_listener(
self._handle_coordinator_update, None
)
)
self.async_on_remove(
self.coordinator_schedules.async_add_listener(
self._handle_coordinator_update, None
)
)

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

async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
# If session in progress, update this session, if not update the first schedule
if session_in_progress(self.hass, self.coordinator.data):
if value == 0:
await self._client.async_apply_session_rule(pre_condition=False)
else:
await self._client.async_apply_session_rule(pre_condition=True, pre_condition_length=int(value))
await asyncio.sleep(1)
await self.coordinator.async_refresh()
else:
if value == 0:
await self._client.async_update_schedule(pre_condition=False)
else:
await self._client.async_update_schedule(pre_condition=True, pre_condition_length=int(value))
await asyncio.sleep(1)
await self.coordinator_schedules.async_refresh()

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

@callback
def _handle_coordinator_update(self) -> None:
"""Get value from data returned from API by coordinator"""
precondition = None
# Set with the same logic as reading
if session_in_progress(self.hass, self.coordinator.data):
enabled = self.coordinator.data['appliedRule'].get(
'preconditioningEnabled', False)
precondition = 0 if not enabled else self.coordinator.data['appliedRule'].get(
'preconditionLengthMins', None)
elif self.coordinator_schedules.data:
enabled = self.coordinator_schedules.data.get(
'preconditioningEnabled', False)
precondition = 0 if not enabled else self.coordinator_schedules.data.get(
'preconditionLengthMins', None)

self._state = precondition

@property
def native_value(self):
return self._state
4 changes: 2 additions & 2 deletions custom_components/ohme/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def unique_id(self):
async def async_set_value(self, value: dt_time) -> None:
"""Update the current value."""
# If session in progress, update this session, if not update the first schedule
if session_in_progress(self.coordinator.data):
if session_in_progress(self.hass, self.coordinator.data):
await self._client.async_apply_session_rule(target_time=(int(value.hour), int(value.minute)))
await asyncio.sleep(1)
await self.coordinator.async_refresh()
Expand All @@ -87,7 +87,7 @@ def _handle_coordinator_update(self) -> None:
"""Get value from data returned from API by coordinator"""
# Read with the same logic as setting
target = None
if session_in_progress(self.coordinator.data):
if session_in_progress(self.hass, self.coordinator.data):
target = self.coordinator.data['appliedRule']['targetTime']
elif self.coordinator_schedules.data:
target = self.coordinator_schedules.data['targetTime']
Expand Down
8 changes: 6 additions & 2 deletions custom_components/ohme/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@
"options": {
"step": {
"init": {
"title": "Update Account Info",
"description": "Update your Ohme account information.",
"data": {
"email": "Email address",
"password": "Password"
"password": "Password",
"never_session_specific": "Never update an ongoing session"
},
"data_description": {
"password": "If you are not changing your credentials, leave the password field empty.",
"never_session_specific": "When adjusting charge percentage, charge target or preconditioning settings, the schedule will always be updated even if a charge session is in progress."
}
}
},
Expand Down
Loading

0 comments on commit dff1362

Please sign in to comment.