diff --git a/README.md b/README.md index 89e3158..82fcde3 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,29 @@ This is an unofficial integration. I have no affiliation with Ohme besides ownin This integration does not currently support social login or accounts with multiple chargers. It has been tested with the following hardware: * Ohme Home Pro [UK] * Ohme Home/Go [UK] +* Ohme ePod [UK] If you find any bugs or would like to request a feature, please open an issue. + +## 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 + +### Manual +1. Download the [latest release](https://github.com/dan-r/HomeAssistant-Ohme/releases) +2. Copy the contents of `custom_components` into the `/custom_components` directory of your Home Assistant installation +3. Restart Home Assistant + + +## Setup +From the Home Assistant Integrations page, search for an add the Ohme integration. If you created your Ohme account through a social login, you will need to 'reset your password' to use this integration. + + ## Entities This integration exposes the following entities: @@ -33,18 +53,20 @@ This integration exposes the following entities: * Buttons * Approve Charge - Approves a charge when 'Pending Approval' is on -## Installation +## 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. -### 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 +The coordinators are listed with their refresh intervals below. Relevant coordinators are also refreshed when using switches and buttons. -### Manual -1. Download the [latest release](https://github.com/dan-r/HomeAssistant-Ohme/releases) -2. Copy the contents of `custom_components` into the `/custom_components` directory of your Home Assistant installation -3. Restart Home Assistant +* OhmeChargeSessionsCoordinator (30s refresh) + * Binary Sensors: All + * Buttons: Approve Charge + * Sensors: Power, current and next slot + * Switches: Max charge, pause charge +* OhmeAccountInfoCoordinator (1m refresh) + * Switches: Lock buttons, require approval and sleep when inactive +* OhmeAdvancedSettingsCoordinator (1m refresh) + * Sensors: CT reading sensor +* OhmeStatisticsCoordinator (30m refresh) + * Sensors: Accumulative energy usage -## Setup -From the Home Assistant Integrations page, search for an add the Ohme integration. If you created your Ohme account through a social login, you will need to 'reset your password' to use this integration. diff --git a/custom_components/ohme/api_client.py b/custom_components/ohme/api_client.py index 776f019..b0c2162 100644 --- a/custom_components/ohme/api_client.py +++ b/custom_components/ohme/api_client.py @@ -18,22 +18,30 @@ def __init__(self, email, password): if email is None or password is None: raise Exception("Credentials not provided") + # Credentials from configuration self._email = email self._password = password + # Charger and its capabilities self._device_info = None self._capabilities = {} + + # Authentication self._token_birth = 0 self._token = None self._refresh_token = None + + # User info self._user_id = "" self._serial = "" + + # Sessions self._session = aiohttp.ClientSession( base_url="https://api.ohme.io") self._auth_session = aiohttp.ClientSession() - # Auth methods + async def async_create_session(self): """Refresh the user auth token from the stored credentials.""" async with self._auth_session.post( @@ -54,7 +62,7 @@ async def async_refresh_session(self): """Refresh auth token if needed.""" if self._token is None: return await self.async_create_session() - + # Don't refresh token unless its over 45 mins old if time() - self._token_birth < 2700: return @@ -76,8 +84,8 @@ async def async_refresh_session(self): self._refresh_token = resp_json['refresh_token'] return True - # Internal methods + def _last_second_of_month_timestamp(self): """Get the last second of this month.""" dt = datetime.today() @@ -139,20 +147,20 @@ async def _get_request(self, url): return await resp.json() - # Simple getters + def is_capable(self, capability): """Return whether or not this model has a given capability.""" return bool(self._capabilities[capability]) - + def get_device_info(self): return self._device_info def get_unique_id(self, name): return f"ohme_{self._serial}_{name}" - # Push methods + async def async_pause_charge(self): """Pause an ongoing charge""" result = await self._post_request(f"/v1/chargeSessions/{self._serial}/stop", skip_json=True) @@ -173,10 +181,13 @@ 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_stop_max_charge(self): - """Stop max charge. - This is more complicated than starting one as we need to give more parameters.""" - result = await self._put_request(f"/v1/chargeSessions/{self._serial}/rule?enableMaxPrice=false&toPercent=80.0&inSeconds=43200") + async def async_apply_charge_rule(self, max_price=False, target_ts=0, target_percent=100, pre_condition=False, pre_condition_length=0): + """Apply charge rule/stop max charge.""" + + max_price = 'true' if max_price else 'false' + pre_condition = 'true' if pre_condition else 'false' + + 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_set_configuration_value(self, values): @@ -184,8 +195,8 @@ async def async_set_configuration_value(self, values): result = await self._put_request(f"/v1/chargeDevices/{self._serial}/appSettings", data=values) return bool(result) - # Pull methods + 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""" @@ -234,10 +245,10 @@ async def async_get_ct_reading(self): return resp['clampAmps'] - # Exceptions class ApiException(Exception): ... + class AuthException(ApiException): ... diff --git a/custom_components/ohme/switch.py b/custom_components/ohme/switch.py index 49b1225..afde853 100644 --- a/custom_components/ohme/switch.py +++ b/custom_components/ohme/switch.py @@ -13,6 +13,7 @@ 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__) @@ -121,6 +122,9 @@ 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) @@ -145,6 +149,10 @@ 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() @@ -159,8 +167,42 @@ async def async_turn_on(self): await self.coordinator.async_refresh() async def async_turn_off(self): - """Turn off the switch.""" - await self._client.async_stop_max_charge() + """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 + ) await asyncio.sleep(1) await self.coordinator.async_refresh() diff --git a/custom_components/ohme/utils.py b/custom_components/ohme/utils.py index 7b079f2..7b209e3 100644 --- a/custom_components/ohme/utils.py +++ b/custom_components/ohme/utils.py @@ -1,5 +1,5 @@ from time import time -from datetime import datetime +from datetime import datetime, timedelta import pytz @@ -29,8 +29,19 @@ def charge_graph_next_slot(charge_start, points): # If the next point has a Y delta of 10+, consider this the start of a slot # This should be 0+ but I had some strange results in testing... revisit if delta > 10: - next_ts = data[idx]["t"] + 1 # 1s added here as it otherwise often rounds down to xx:59:59 + # 1s added here as it otherwise often rounds down to xx:59:59 + next_ts = data[idx]["t"] + 1 break # This needs to be presented with tzinfo or Home Assistant will reject it return None if next_ts is None else datetime.utcfromtimestamp(next_ts).replace(tzinfo=pytz.utc) + + +def time_next_occurs(hour, minute): + """Find when this time next occurs.""" + current = datetime.now() + target = current.replace(hour=hour, minute=minute, second=0, microsecond=0) + while target <= current: + target = target + timedelta(days=1) + + return target