From dff136201c9fe414a7121fb06f8195f28ef46c44 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Thu, 18 Jan 2024 17:50:28 +0100 Subject: [PATCH] Added preconditioning support and 'never session specific' option (#43) * 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 --- .github/FUNDING.yml | 1 + README.md | 16 ++- custom_components/ohme/__init__.py | 30 +++--- custom_components/ohme/api_client.py | 8 +- custom_components/ohme/config_flow.py | 40 ++++++-- custom_components/ohme/const.py | 4 +- custom_components/ohme/number.py | 102 +++++++++++++++++++- custom_components/ohme/time.py | 4 +- custom_components/ohme/translations/en.json | 8 +- custom_components/ohme/utils.py | 11 ++- 10 files changed, 188 insertions(+), 36 deletions(-) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..5ebb394 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: dan-r diff --git a/README.md b/README.md index 3779ced..ac196ca 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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) @@ -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) diff --git a/custom_components/ohme/__init__.py b/custom_components/ohme/__init__.py index ca7110f..732118d 100644 --- a/custom_components/ohme/__init__.py +++ b/custom_components/ohme/__init__.py @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/custom_components/ohme/api_client.py b/custom_components/ohme/api_client.py index 41ff9ab..7f124ad 100644 --- a/custom_components/ohme/api_client.py +++ b/custom_components/ohme/api_client.py @@ -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() @@ -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 diff --git a/custom_components/ohme/config_flow.py b/custom_components/ohme/config_flow.py index ec566a7..3cef071 100644 --- a/custom_components/ohme/config_flow.py +++ b/custom_components/ohme/config_flow.py @@ -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( @@ -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 ) diff --git a/custom_components/ohme/const.py b/custom_components/ohme/const.py index ae0b25f..31ab057 100644 --- a/custom_components/ohme/const.py +++ b/custom_components/ohme/const.py @@ -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 diff --git a/custom_components/ohme/number.py b/custom_components/ohme/number.py index cc52524..6ea6d03 100644 --- a/custom_components/ohme/number.py +++ b/custom_components/ohme/number.py @@ -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, @@ -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) @@ -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.""" @@ -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() @@ -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']) @@ -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 diff --git a/custom_components/ohme/time.py b/custom_components/ohme/time.py index 6afc7e2..dee2dbb 100644 --- a/custom_components/ohme/time.py +++ b/custom_components/ohme/time.py @@ -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() @@ -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'] diff --git a/custom_components/ohme/translations/en.json b/custom_components/ohme/translations/en.json index c1c83e5..3d8d8f0 100644 --- a/custom_components/ohme/translations/en.json +++ b/custom_components/ohme/translations/en.json @@ -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." } } }, diff --git a/custom_components/ohme/utils.py b/custom_components/ohme/utils.py index 9d0a744..269771d 100644 --- a/custom_components/ohme/utils.py +++ b/custom_components/ohme/utils.py @@ -1,5 +1,6 @@ from time import time from datetime import datetime, timedelta +from .const import DOMAIN, DATA_OPTIONS import pytz @@ -74,9 +75,13 @@ def time_next_occurs(hour, minute): return target -def session_in_progress(data): +def session_in_progress(hass, data): """Is there a session in progress? Used to check if we should update the current session rather than the first schedule.""" + # If config option set, never update session specific schedule + if get_option(hass, "never_session_specific"): + return False + # Default to False with no data if not data: return False @@ -86,3 +91,7 @@ def session_in_progress(data): return False return True + +def get_option(hass, option): + """Return option value, default to False.""" + return hass.data[DOMAIN][DATA_OPTIONS].get(option, None)