From 66bd62110fc6b88092eef76e53dcec9b5ad83aef Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Mon, 7 Oct 2024 16:00:46 +0100 Subject: [PATCH] A lot of changes (#97) * Logic to collapse slots * Linked to batpred docs * Add 'Don't collapse charge slots' option * Fixed next slot logic * More next slot bugfixes * Preliminary fix for #88 * Update install instructions * Hide price cap settings for intelligent octopus * Add solar boost switch * Bump version --- README.md | 13 ++-- custom_components/ohme/api_client.py | 24 +++++-- custom_components/ohme/config_flow.py | 3 + custom_components/ohme/const.py | 2 +- custom_components/ohme/number.py | 15 +++-- custom_components/ohme/sensor.py | 14 ++-- custom_components/ohme/switch.py | 72 ++++++++++++++++++++- custom_components/ohme/translations/en.json | 4 +- custom_components/ohme/utils.py | 42 ++++++++++-- 9 files changed, 157 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 56b747c..4139476 100644 --- a/README.md +++ b/README.md @@ -14,16 +14,15 @@ This integration has been tested with the following hardware: * Ohme ePod [v2.12] ## External Software -The 'Charge Slot Active' binary sensor mimics the `planned_dispatches` and `completed_dispatches` attributes from the [Octopus Energy](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy) integration, so should support external software which reads this such as [predbat](https://github.com/springfall2008/batpred). +The 'Charge Slot Active' binary sensor mimics the `planned_dispatches` and `completed_dispatches` attributes from the [Octopus Energy](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy) integration, so should support external software which reads this such as [predbat](https://springfall2008.github.io/batpred/devices/#ohme). ## Installation ### HACS This is the recommended installation method. -1. Add this repository to HACS as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories) -2. Search for and install the Ohme addon from HACS -3. Restart Home Assistant +1. Search for and install the Ohme addon from HACS +2. Restart Home Assistant ### Manual 1. Download the [latest release](https://github.com/dan-r/HomeAssistant-Ohme/releases) @@ -64,15 +63,16 @@ This integration exposes the following entities: * Lock Buttons - Locks buttons on charger * Require Approval - Require approval to start a charge * Sleep When Inactive - Charger screen & lights will automatically turn off + * Solar Boost * 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 - * Enable Price Cap - Whether price cap is applied + * Enable Price Cap - Whether price cap is applied. _Due to changes by Ohme, this will not show for Intelligent Octopus users._ * 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 * Preconditioning - Change pre-conditioning time. 0 is off - * Price Cap - Maximum charge price + * Price Cap - Maximum charge price. _Due to changes by Ohme, this will not show for Intelligent Octopus users._ * Time * Target Time - Change the target time * Buttons @@ -81,6 +81,7 @@ This integration exposes the following entities: ## 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. +* Don't collapse charge slots - By default, adjacent slots are merged into one. This option shows every slot, as shown in the Ohme app. * Enable accumulative energy usage sensor - Enable the sensor showing an all-time incrementing energy usage counter. This causes issues with some accounts. diff --git a/custom_components/ohme/api_client.py b/custom_components/ohme/api_client.py index dd743c6..5861d72 100644 --- a/custom_components/ohme/api_client.py +++ b/custom_components/ohme/api_client.py @@ -28,6 +28,8 @@ def __init__(self, email, password): self._capabilities = {} self._ct_connected = False self._provision_date = None + self._disable_cap = False + self._solar_capable = False # Authentication self._token_birth = 0 @@ -168,6 +170,12 @@ def is_capable(self, capability): """Return whether or not this model has a given capability.""" return bool(self._capabilities[capability]) + def solar_capable(self): + return self._solar_capable + + def cap_available(self): + return not self._disable_cap + def get_device_info(self): return self._device_info @@ -212,8 +220,8 @@ async def async_apply_session_rule(self, max_price=None, target_time=None, targe 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 + pre_condition_length = self._last_rule['preconditionLengthMins'] if ( + 'preconditionLengthMins' in self._last_rule and self._last_rule['preconditionLengthMins'] is not None) else 30 if target_time is None: # Default to 9am @@ -230,7 +238,7 @@ async def async_apply_session_rule(self, max_price=None, target_time=None, targe 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_change_price_cap(self, enabled=None, cap=None): """Change price cap settings.""" settings = await self._get_request("/v1/users/me/settings") @@ -261,7 +269,8 @@ async def async_update_schedule(self, target_percent=None, target_time=None, pre 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) + rule['targetTime'] = (target_time[0] * 3600) + \ + (target_time[1] * 60) # Update pre-conditioning if provided if pre_condition is not None: @@ -317,6 +326,13 @@ async def async_update_device_info(self, is_retry=False): self._device_info = info self._provision_date = device['provisioningTs'] + if resp['tariff'] is not None and resp['tariff']['dsrTariff']: + self._disable_cap = True + + solar_modes = device['modelCapabilities']['solarModes'] + if isinstance(solar_modes, list) and len(solar_modes) == 1: + self._solar_capable = True + return True async def async_get_charge_statistics(self): diff --git a/custom_components/ohme/config_flow.py b/custom_components/ohme/config_flow.py index fb3ad2a..1c19902 100644 --- a/custom_components/ohme/config_flow.py +++ b/custom_components/ohme/config_flow.py @@ -87,6 +87,9 @@ async def async_step_init(self, options): vol.Required( "never_session_specific", default=self._config_entry.options.get("never_session_specific", False) ) : bool, + vol.Required( + "never_collapse_slots", default=self._config_entry.options.get("never_collapse_slots", False) + ) : bool, vol.Required( "enable_accumulative_energy", default=self._config_entry.options.get("enable_accumulative_energy", False) ) : bool diff --git a/custom_components/ohme/const.py b/custom_components/ohme/const.py index b4d8ca8..4016b88 100644 --- a/custom_components/ohme/const.py +++ b/custom_components/ohme/const.py @@ -1,7 +1,7 @@ """Component constants""" DOMAIN = "ohme" USER_AGENT = "dan-r-homeassistant-ohme" -INTEGRATION_VERSION = "0.9.0" +INTEGRATION_VERSION = "1.0.0" CONFIG_VERSION = 1 ENTITY_TYPES = ["sensor", "binary_sensor", "switch", "button", "number", "time"] diff --git a/custom_components/ohme/number.py b/custom_components/ohme/number.py index e076b5e..3be54c3 100644 --- a/custom_components/ohme/number.py +++ b/custom_components/ohme/number.py @@ -22,8 +22,12 @@ async def async_setup_entry( numbers = [TargetPercentNumber( coordinators[COORDINATOR_CHARGESESSIONS], coordinators[COORDINATOR_SCHEDULES], hass, client), PreconditioningNumber( - coordinators[COORDINATOR_CHARGESESSIONS], coordinators[COORDINATOR_SCHEDULES], hass, client), - PriceCapNumber(coordinators[COORDINATOR_ACCOUNTINFO], hass, client)] + coordinators[COORDINATOR_CHARGESESSIONS], coordinators[COORDINATOR_SCHEDULES], hass, client)] + + if client.cap_available(): + numbers.append( + PriceCapNumber(coordinators[COORDINATOR_ACCOUNTINFO], hass, client) + ) async_add_entities(numbers, update_before_add=True) @@ -234,15 +238,16 @@ async def async_set_native_value(self, value: float) -> None: def native_unit_of_measurement(self): if self.coordinator.data is None: return None - + penny_unit = { "GBP": "p", "EUR": "c" } - currency = self.coordinator.data["userSettings"].get("currencyCode", "XXX") + currency = self.coordinator.data["userSettings"].get( + "currencyCode", "XXX") return penny_unit.get(currency, f"{currency}/100") - + @property def icon(self): """Icon of the sensor.""" diff --git a/custom_components/ohme/sensor.py b/custom_components/ohme/sensor.py index 73f3d8c..e397a3e 100644 --- a/custom_components/ohme/sensor.py +++ b/custom_components/ohme/sensor.py @@ -1,6 +1,5 @@ """Platform for sensor integration.""" from __future__ import annotations -from functools import reduce from homeassistant.components.sensor import ( SensorDeviceClass, SensorStateClass, @@ -15,7 +14,7 @@ from homeassistant.util.dt import (utcnow) from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, DATA_SLOTS, COORDINATOR_CHARGESESSIONS, COORDINATOR_STATISTICS, COORDINATOR_ADVANCED from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator, OhmeAdvancedSettingsCoordinator -from .utils import next_slot, get_option, slot_list +from .utils import next_slot, get_option, slot_list, slot_list_str _LOGGER = logging.getLogger(__name__) @@ -332,6 +331,7 @@ def __init__( self._attributes = {} self._last_updated = None self._client = client + self._hass = hass self.entity_id = generate_entity_id( "sensor.{}", "ohme_next_slot", hass=hass) @@ -360,7 +360,7 @@ def _handle_coordinator_update(self) -> None: if self.coordinator.data is None or self.coordinator.data["mode"] == "DISCONNECTED": self._state = None else: - self._state = next_slot(self.coordinator.data)['start'] + self._state = next_slot(self._hass, self.coordinator.data)['start'] self._last_updated = utcnow() @@ -383,6 +383,7 @@ def __init__( self._attributes = {} self._last_updated = None self._client = client + self._hass = hass self.entity_id = generate_entity_id( "sensor.{}", "ohme_next_slot_end", hass=hass) @@ -411,7 +412,7 @@ def _handle_coordinator_update(self) -> None: if self.coordinator.data is None or self.coordinator.data["mode"] == "DISCONNECTED": self._state = None else: - self._state = next_slot(self.coordinator.data)['end'] + self._state = next_slot(self._hass, self.coordinator.data)['end'] self._last_updated = utcnow() @@ -468,10 +469,7 @@ def _handle_coordinator_update(self) -> None: self._hass.data[DOMAIN][DATA_SLOTS] = slots # Convert list to text - self._state = reduce(lambda acc, slot: acc + f"{slot['start'].strftime('%H:%M')}-{slot['end'].strftime('%H:%M')}, ", slots, "")[:-2] - - # Make sure we return None/Unknown if the list is empty - self._state = None if self._state == "" else self._state + self._state = slot_list_str(self._hass, slots) self._last_updated = utcnow() self.async_write_ha_state() diff --git a/custom_components/ohme/switch.py b/custom_components/ohme/switch.py index 498f4ea..5c3fe7f 100644 --- a/custom_components/ohme/switch.py +++ b/custom_components/ohme/switch.py @@ -30,9 +30,18 @@ async def async_setup_entry( client = hass.data[DOMAIN][DATA_CLIENT] switches = [OhmePauseChargeSwitch(coordinator, hass, client), - OhmeMaxChargeSwitch(coordinator, hass, client), - OhmePriceCapSwitch(accountinfo_coordinator, hass, client)] + OhmeMaxChargeSwitch(coordinator, hass, client) + ] + if client.cap_available(): + switches.append( + OhmePriceCapSwitch(accountinfo_coordinator, hass, client) + ) + + if client.solar_capable(): + switches.append( + OhmeSolarBoostSwitch(accountinfo_coordinator, hass, client) + ) if client.is_capable("buttonsLockable"): switches.append( OhmeConfigurationSwitch( @@ -226,6 +235,62 @@ async def async_turn_off(self): await self.coordinator.async_refresh() +class OhmeSolarBoostSwitch(CoordinatorEntity[OhmeAccountInfoCoordinator], SwitchEntity): + """Switch for changing configuration options.""" + + def __init__(self, coordinator, hass: HomeAssistant, client): + super().__init__(coordinator=coordinator) + + self._client = client + + self._state = False + self._last_updated = None + self._attributes = {} + + self._attr_name = "Solar Boost" + self.entity_id = generate_entity_id( + "switch.{}", "ohme_solar_boost", 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("solarMode") + + @property + def icon(self): + """Icon of the switch.""" + return "mdi:solar-power" + + @callback + def _handle_coordinator_update(self) -> None: + """Determine configuration value.""" + if self.coordinator.data is None: + self._attr_is_on = None + else: + settings = self.coordinator.data["chargeDevices"][0]["optionalSettings"] + self._attr_is_on = bool(settings["solarMode"] == "ZERO_EXPORT") + + self._last_updated = utcnow() + + self.async_write_ha_state() + + async def async_turn_on(self): + """Turn on the switch.""" + await self._client.async_set_configuration_value({"solarMode": "ZERO_EXPORT"}) + + await asyncio.sleep(1) + await self.coordinator.async_refresh() + + async def async_turn_off(self): + """Turn off the switch.""" + await self._client.async_set_configuration_value({"solarMode": "IGNORE"}) + + await asyncio.sleep(1) + await self.coordinator.async_refresh() + + class OhmePriceCapSwitch(CoordinatorEntity[OhmeAccountInfoCoordinator], SwitchEntity): """Switch for enabling price cap.""" _attr_name = "Enable Price Cap" @@ -256,7 +321,8 @@ def _handle_coordinator_update(self) -> None: if self.coordinator.data is None: self._attr_is_on = None else: - self._attr_is_on = bool(self.coordinator.data["userSettings"]["chargeSettings"][0]["enabled"]) + self._attr_is_on = bool( + self.coordinator.data["userSettings"]["chargeSettings"][0]["enabled"]) self._last_updated = utcnow() diff --git a/custom_components/ohme/translations/en.json b/custom_components/ohme/translations/en.json index 6c05720..4c397d8 100644 --- a/custom_components/ohme/translations/en.json +++ b/custom_components/ohme/translations/en.json @@ -23,11 +23,13 @@ "email": "Email address", "password": "Password", "never_session_specific": "Never update an ongoing session", + "never_collapse_slots": "Don't collapse charge slots", "enable_accumulative_energy": "Enable accumulative energy sensor" }, "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." + "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.", + "never_collapse_slots": "By default, adjacent slots are merged into one. This option shows every slot, as shown in the Ohme app." } } }, diff --git a/custom_components/ohme/utils.py b/custom_components/ohme/utils.py index baf4bd8..e5cfe41 100644 --- a/custom_components/ohme/utils.py +++ b/custom_components/ohme/utils.py @@ -1,4 +1,4 @@ -from time import time +from functools import reduce from datetime import datetime, timedelta from .const import DOMAIN, DATA_OPTIONS import pytz @@ -6,20 +6,27 @@ # _LOGGER = logging.getLogger(__name__) -def next_slot(data): +def next_slot(hass, data): """Get the next charge slot start/end times.""" slots = slot_list(data) + collapse_slots = not get_option(hass, "never_collapse_slots", False) + start = None end = None # Loop through slots for slot in slots: # Only take the first slot start/end that matches. These are in order. + if end is None and slot['end'] > datetime.now().astimezone(): + end = slot['end'] + if start is None and slot['start'] > datetime.now().astimezone(): start = slot['start'] - if end is None and slot['end'] > datetime.now().astimezone(): + elif collapse_slots and slot['start'] == end: end = slot['end'] - + elif start is not None and end is not None: + break + return { "start": start, "end": end @@ -54,6 +61,33 @@ def slot_list(data): return slots +def slot_list_str(hass, slots): + """Convert slot list to string.""" + + # Convert list to tuples of times + t_slots = [] + for slot in slots: + t_slots.append((slot['start'].strftime('%H:%M'), slot['end'].strftime('%H:%M'))) + + state = [] + + if not get_option(hass, "never_collapse_slots", False): + # Collapse slots so consecutive slots become one + for i in range(len(t_slots)): + if not state or state[-1][1] != t_slots[i][0]: + state.append(t_slots[i]) + else: + state[-1] = (state[-1][0], t_slots[i][1]) + else: + state = t_slots + + # Convert list of tuples to string + state = reduce(lambda acc, slot: acc + f"{slot[0]}-{slot[1]}, ", state, "")[:-2] + + # Make sure we return None/Unknown if the list is empty + return None if state == "" else state + + def in_slot(data): """Are we currently in a charge slot?""" slots = slot_list(data)