From 2dc6e0eaa80644d0fc74e5c7a365b76fca497b0e Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Fri, 29 Dec 2023 15:25:00 +0000 Subject: [PATCH 01/10] Added ePod to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 89e3158..0b06de0 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ 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. From 57fc7ba0ed2ba839665d75ce1946a7ce14050f84 Mon Sep 17 00:00:00 2001 From: Daniel Raper Date: Fri, 29 Dec 2023 18:56:02 +0000 Subject: [PATCH 02/10] Added coordinators info to README --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 0b06de0..039753f 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,23 @@ This integration exposes the following entities: * Buttons * Approve Charge - Approves a charge when 'Pending Approval' is on +## 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. + +The coordinators are listed with their refresh intervals below. Relevant coordinators are also refreshed when using switches and buttons. + +* 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 + ## Installation ### HACS From 6e12e8c11c21655adf19508e49d8f839ff6284da Mon Sep 17 00:00:00 2001 From: Daniel Raper Date: Fri, 29 Dec 2023 19:19:59 +0000 Subject: [PATCH 03/10] Shift installation and setup to the top --- README.md | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 039753f..82fcde3 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,25 @@ This integration does not currently support social login or accounts with multip 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: @@ -51,18 +70,3 @@ The coordinators are listed with their refresh intervals below. Relevant coordin * OhmeStatisticsCoordinator (30m refresh) * Sensors: Accumulative energy usage -## 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. From 27efe8764a6dbe9263fde1cfe3de76498ce13d0b Mon Sep 17 00:00:00 2001 From: Daniel Raper Date: Fri, 29 Dec 2023 20:36:50 +0000 Subject: [PATCH 04/10] Add rule caching so stop max charge works properly --- custom_components/ohme/api_client.py | 35 +++++++++++++-------- custom_components/ohme/switch.py | 46 ++++++++++++++++++++++++++-- custom_components/ohme/utils.py | 15 +++++++-- 3 files changed, 80 insertions(+), 16 deletions(-) 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 From 44dfc1a83ae27de1f2e9e145f60f2b438b63cdd2 Mon Sep 17 00:00:00 2001 From: Daniel Raper Date: Sat, 30 Dec 2023 14:59:43 +0000 Subject: [PATCH 05/10] Add CT detection --- custom_components/ohme/api_client.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/custom_components/ohme/api_client.py b/custom_components/ohme/api_client.py index b0c2162..53451b5 100644 --- a/custom_components/ohme/api_client.py +++ b/custom_components/ohme/api_client.py @@ -25,6 +25,7 @@ def __init__(self, email, password): # Charger and its capabilities self._device_info = None self._capabilities = {} + self._ct_connected = False # Authentication self._token_birth = 0 @@ -149,6 +150,10 @@ async def _get_request(self, url): # Simple getters + def ct_connected(self): + """Is CT clamp connected.""" + return self._ct_connected + def is_capable(self, capability): """Return whether or not this model has a given capability.""" return bool(self._capabilities[capability]) @@ -241,6 +246,10 @@ async def async_get_charge_statistics(self): async def async_get_ct_reading(self): """Get CT clamp reading.""" resp = await self._get_request(f"/v1/chargeDevices/{self._serial}/advancedSettings") + + # If we ever get a reading above 0, assume CT connected + if resp['clampAmps'] > 0: + self._ct_connected = True return resp['clampAmps'] From 51569e02fa591c3a08fdbc08b0d20e52cd42dfb7 Mon Sep 17 00:00:00 2001 From: Daniel Raper Date: Sat, 30 Dec 2023 15:30:29 +0000 Subject: [PATCH 06/10] Add next slot ends sensor --- README.md | 5 ++- custom_components/ohme/sensor.py | 63 +++++++++++++++++++++++++++++--- custom_components/ohme/utils.py | 18 ++++++--- 3 files changed, 73 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 82fcde3..472b4a3 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,8 @@ This integration exposes the following entities: * Current Draw (Amps) - Current draw of connected car * CT Reading (Amps) - Reading from attached CT clamp * Accumulative Energy Usage (kWh) - Total energy used by the charger - * Next Smart Charge Slot - The next time your car will start charging according to the Ohme-generated charge plan + * Next Charge Slot Start - The next time your car will start charging according to the Ohme-generated charge plan + * Next Charge Slot End - The next time your car will stop charging according to the Ohme-generated charge plan * Switches (Settings) - Only options available to your charger model will show * Lock Buttons - Locks buttons on charger * Require Approval - Require approval to start a charge @@ -61,7 +62,7 @@ The coordinators are listed with their refresh intervals below. Relevant coordin * OhmeChargeSessionsCoordinator (30s refresh) * Binary Sensors: All * Buttons: Approve Charge - * Sensors: Power, current and next slot + * Sensors: Power, current and next slot (start & end) * Switches: Max charge, pause charge * OhmeAccountInfoCoordinator (1m refresh) * Switches: Lock buttons, require approval and sleep when inactive diff --git a/custom_components/ohme/sensor.py b/custom_components/ohme/sensor.py index 1880e52..632dc17 100644 --- a/custom_components/ohme/sensor.py +++ b/custom_components/ohme/sensor.py @@ -32,7 +32,8 @@ async def async_setup_entry( CurrentDrawSensor(coordinator, hass, client), CTSensor(adv_coordinator, hass, client), EnergyUsageSensor(stats_coordinator, hass, client), - NextSlotSensor(coordinator, hass, client)] + NextSlotEndSensor(coordinator, hass, client), + NextSlotStartSensor(coordinator, hass, client)] async_add_entities(sensors, update_before_add=True) @@ -206,9 +207,9 @@ def native_value(self): return None -class NextSlotSensor(CoordinatorEntity[OhmeStatisticsCoordinator], SensorEntity): - """Sensor for next smart charge slot.""" - _attr_name = "Next Smart Charge Slot" +class NextSlotStartSensor(CoordinatorEntity[OhmeStatisticsCoordinator], SensorEntity): + """Sensor for next smart charge slot start time.""" + _attr_name = "Next Charge Slot Start" _attr_device_class = SensorDeviceClass.TIMESTAMP def __init__( @@ -234,6 +235,58 @@ def unique_id(self) -> str: """Return the unique ID of the sensor.""" return self._client.get_unique_id("next_slot") + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:clock-star-four-points" + + @property + def native_value(self): + """Return pre-calculated state.""" + return self._state + + @callback + def _handle_coordinator_update(self) -> None: + """Calculate next timeslot. This is a bit slow so we only update on coordinator data update.""" + if self.coordinator.data is None or self.coordinator.data["mode"] == "DISCONNECTED": + self._state = None + else: + self._state = charge_graph_next_slot( + self.coordinator.data['startTime'], self.coordinator.data['chargeGraph']['points'])['start'] + + self._last_updated = utcnow() + + self.async_write_ha_state() + + +class NextSlotEndSensor(CoordinatorEntity[OhmeStatisticsCoordinator], SensorEntity): + """Sensor for next smart charge slot end time.""" + _attr_name = "Next Charge Slot End" + _attr_device_class = SensorDeviceClass.TIMESTAMP + + def __init__( + self, + coordinator: OhmeChargeSessionsCoordinator, + hass: HomeAssistant, + client): + super().__init__(coordinator=coordinator) + + self._state = None + self._attributes = {} + self._last_updated = None + self._client = client + + self.entity_id = generate_entity_id( + "sensor.{}", "ohme_next_slot_end", hass=hass) + + self._attr_device_info = hass.data[DOMAIN][DATA_CLIENT].get_device_info( + ) + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return self._client.get_unique_id("next_slot_end") + @property def icon(self): """Icon of the sensor.""" @@ -251,7 +304,7 @@ def _handle_coordinator_update(self) -> None: self._state = None else: self._state = charge_graph_next_slot( - self.coordinator.data['startTime'], self.coordinator.data['chargeGraph']['points']) + self.coordinator.data['startTime'], self.coordinator.data['chargeGraph']['points'])['end'] self._last_updated = utcnow() diff --git a/custom_components/ohme/utils.py b/custom_components/ohme/utils.py index 7b209e3..7b7b21b 100644 --- a/custom_components/ohme/utils.py +++ b/custom_components/ohme/utils.py @@ -17,9 +17,10 @@ def charge_graph_next_slot(charge_start, points): # Give up if we have less than 3 points if len(data) < 3: - return None + return {"start": None, "end": None} - next_ts = None + start_ts = None + end_ts = None # Loop through every remaining value, skipping the last for idx in range(0, len(data) - 1): @@ -28,13 +29,18 @@ 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: + if delta > 10 and not start_ts: # 1s added here as it otherwise often rounds down to xx:59:59 - next_ts = data[idx]["t"] + 1 + start_ts = data[idx]["t"] + 1 + elif start_ts and delta == 0: # If we have seen a start and see a delta of 0, this is the end + end_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) + # These need to be presented with tzinfo or Home Assistant will reject them + return { + "start": datetime.utcfromtimestamp(start_ts).replace(tzinfo=pytz.utc) if start_ts else None, + "end": datetime.utcfromtimestamp(end_ts).replace(tzinfo=pytz.utc) if end_ts else None, + } def time_next_occurs(hour, minute): From 7cafdba1cfa86e33149a93f9edbebcf724334b40 Mon Sep 17 00:00:00 2001 From: Daniel Raper Date: Sat, 30 Dec 2023 15:41:09 +0000 Subject: [PATCH 07/10] Added voltage sensor --- README.md | 10 ++++--- custom_components/ohme/sensor.py | 47 ++++++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 472b4a3..dc6b23e 100644 --- a/README.md +++ b/README.md @@ -37,18 +37,20 @@ This integration exposes the following entities: * Car Connected - On when a car is plugged in * Car Charging - On when a car is connected and drawing power * Pending Approval - On when a car is connected and waiting for approval -* Sensors +* Sensors (Charge power) - **These are only available during a charge session** * Power Draw (Watts) - Power draw of connected car * Current Draw (Amps) - Current draw of connected car + * Voltage (Volts) - Voltage reading +* Sensors (Other) * CT Reading (Amps) - Reading from attached CT clamp * Accumulative Energy Usage (kWh) - Total energy used by the charger * Next Charge Slot Start - The next time your car will start charging according to the Ohme-generated charge plan * Next Charge Slot End - The next time your car will stop charging according to the Ohme-generated charge plan -* Switches (Settings) - Only options available to your charger model will show +* Switches (Settings) - **Only options available to your charger model will show** * Lock Buttons - Locks buttons on charger * Require Approval - Require approval to start a charge * Sleep When Inactive - Charger screen & lights will automatically turn off -* Switches (Charge state) - These are only functional when a car is connected +* 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 * Buttons @@ -62,7 +64,7 @@ The coordinators are listed with their refresh intervals below. Relevant coordin * OhmeChargeSessionsCoordinator (30s refresh) * Binary Sensors: All * Buttons: Approve Charge - * Sensors: Power, current and next slot (start & end) + * Sensors: Power, current, voltage and next slot (start & end) * Switches: Max charge, pause charge * OhmeAccountInfoCoordinator (1m refresh) * Switches: Lock buttons, require approval and sleep when inactive diff --git a/custom_components/ohme/sensor.py b/custom_components/ohme/sensor.py index 632dc17..633baa3 100644 --- a/custom_components/ohme/sensor.py +++ b/custom_components/ohme/sensor.py @@ -6,12 +6,12 @@ SensorEntity ) from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.const import UnitOfPower, UnitOfEnergy, UnitOfElectricCurrent +from homeassistant.const import UnitOfPower, UnitOfEnergy, UnitOfElectricCurrent, UnitOfElectricPotential from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import generate_entity_id from homeassistant.util.dt import (utcnow) from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, COORDINATOR_STATISTICS, COORDINATOR_ADVANCED -from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator, OhmeAdvancedSettingsCoordinator +from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator from .utils import charge_graph_next_slot @@ -30,6 +30,7 @@ async def async_setup_entry( sensors = [PowerDrawSensor(coordinator, hass, client), CurrentDrawSensor(coordinator, hass, client), + VoltageSensor(coordinator, hass, client), CTSensor(adv_coordinator, hass, client), EnergyUsageSensor(stats_coordinator, hass, client), NextSlotEndSensor(coordinator, hass, client), @@ -122,6 +123,48 @@ def native_value(self): return 0 +class VoltageSensor(CoordinatorEntity[OhmeChargeSessionsCoordinator], SensorEntity): + """Sensor for EVSE voltage.""" + _attr_name = "Voltage" + _attr_device_class = SensorDeviceClass.VOLTAGE + _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT + + def __init__( + self, + coordinator: OhmeChargeSessionsCoordinator, + hass: HomeAssistant, + client): + super().__init__(coordinator=coordinator) + + self._state = None + self._attributes = {} + self._last_updated = None + self._client = client + + self.entity_id = generate_entity_id( + "sensor.{}", "ohme_voltage", hass=hass) + + self._attr_device_info = hass.data[DOMAIN][DATA_CLIENT].get_device_info( + ) + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return self._client.get_unique_id("voltage") + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:sine-wave" + + @property + def native_value(self): + """Get value from data returned from API by coordinator""" + if self.coordinator.data and self.coordinator.data['power']: + return self.coordinator.data['power']['volt'] + return None + + class CTSensor(CoordinatorEntity[OhmeChargeSessionsCoordinator], SensorEntity): """Sensor for car power draw.""" _attr_name = "CT Reading" From 18ef3a583eb0ed523e16dc19ae084564eb3e97c4 Mon Sep 17 00:00:00 2001 From: Daniel Raper Date: Sat, 30 Dec 2023 16:41:31 +0000 Subject: [PATCH 08/10] Added charge slot active sensor --- README.md | 1 + custom_components/ohme/binary_sensor.py | 72 ++++++++++++++++++++++--- custom_components/ohme/sensor.py | 12 ++--- custom_components/ohme/utils.py | 38 ++++++++++--- 4 files changed, 102 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index dc6b23e..07f51a9 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ This integration exposes the following entities: * Car Connected - On when a car is plugged in * Car Charging - On when a car is connected and drawing power * Pending Approval - On when a car is connected and waiting for approval + * Charge Slot Active - On when a charge slot is in progress according to the Ohme-generated charge plan * Sensors (Charge power) - **These are only available during a charge session** * Power Draw (Watts) - Power draw of connected car * Current Draw (Amps) - Current draw of connected car diff --git a/custom_components/ohme/binary_sensor.py b/custom_components/ohme/binary_sensor.py index e41f90f..d8bad2b 100644 --- a/custom_components/ohme/binary_sensor.py +++ b/custom_components/ohme/binary_sensor.py @@ -6,10 +6,12 @@ BinarySensorEntity ) from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import generate_entity_id +from homeassistant.util.dt import (utcnow) from .const import DOMAIN, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, DATA_CLIENT from .coordinator import OhmeChargeSessionsCoordinator +from .utils import charge_graph_in_slot async def async_setup_entry( @@ -21,14 +23,15 @@ async def async_setup_entry( client = hass.data[DOMAIN][DATA_CLIENT] coordinator = hass.data[DOMAIN][DATA_COORDINATORS][COORDINATOR_CHARGESESSIONS] - sensors = [ConnectedSensor(coordinator, hass, client), - ChargingSensor(coordinator, hass, client), - PendingApprovalSensor(coordinator, hass, client)] + sensors = [ConnectedBinarySensor(coordinator, hass, client), + ChargingBinarySensor(coordinator, hass, client), + PendingApprovalBinarySensor(coordinator, hass, client), + CurrentSlotBinarySensor(coordinator, hass, client)] async_add_entities(sensors, update_before_add=True) -class ConnectedSensor( +class ConnectedBinarySensor( CoordinatorEntity[OhmeChargeSessionsCoordinator], BinarySensorEntity): """Binary sensor for if car is plugged in.""" @@ -74,7 +77,7 @@ def is_on(self) -> bool: return self._state -class ChargingSensor( +class ChargingBinarySensor( CoordinatorEntity[OhmeChargeSessionsCoordinator], BinarySensorEntity): """Binary sensor for if car is charging.""" @@ -121,7 +124,7 @@ def is_on(self) -> bool: return self._state -class PendingApprovalSensor( +class PendingApprovalBinarySensor( CoordinatorEntity[OhmeChargeSessionsCoordinator], BinarySensorEntity): """Binary sensor for if a charge is pending approval.""" @@ -165,3 +168,58 @@ def is_on(self) -> bool: self.coordinator.data["mode"] == "PENDING_APPROVAL") return self._state + + +class CurrentSlotBinarySensor( + CoordinatorEntity[OhmeChargeSessionsCoordinator], + BinarySensorEntity): + """Binary sensor for if we are currently in a smart charge slot.""" + + _attr_name = "Charge Slot Active" + + def __init__( + self, + coordinator: OhmeChargeSessionsCoordinator, + hass: HomeAssistant, + client): + super().__init__(coordinator=coordinator) + + self._attributes = {} + self._last_updated = None + self._state = False + self._client = client + + self.entity_id = generate_entity_id( + "binary_sensor.{}", "ohme_slot_active", hass=hass) + + self._attr_device_info = hass.data[DOMAIN][DATA_CLIENT].get_device_info( + ) + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:calendar-check" + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return self._client.get_unique_id("ohme_slot_active") + + @property + def is_on(self) -> bool: + return self._state + + @callback + def _handle_coordinator_update(self) -> None: + """Are we in a charge slot? This is a bit slow so we only update on coordinator data update.""" + if self.coordinator.data is None: + self._state = None + elif self.coordinator.data["mode"] == "DISCONNECTED": + self._state = False + else: + self._state = charge_graph_in_slot( + self.coordinator.data['startTime'], self.coordinator.data['chargeGraph']['points']) + + self._last_updated = utcnow() + + self.async_write_ha_state() diff --git a/custom_components/ohme/sensor.py b/custom_components/ohme/sensor.py index 633baa3..dd64c3b 100644 --- a/custom_components/ohme/sensor.py +++ b/custom_components/ohme/sensor.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entity import generate_entity_id from homeassistant.util.dt import (utcnow) from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, COORDINATOR_STATISTICS, COORDINATOR_ADVANCED -from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator +from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator, OhmeAdvancedSettingsCoordinator from .utils import charge_graph_next_slot @@ -165,7 +165,7 @@ def native_value(self): return None -class CTSensor(CoordinatorEntity[OhmeChargeSessionsCoordinator], SensorEntity): +class CTSensor(CoordinatorEntity[OhmeAdvancedSettingsCoordinator], SensorEntity): """Sensor for car power draw.""" _attr_name = "CT Reading" _attr_device_class = SensorDeviceClass.CURRENT @@ -173,7 +173,7 @@ class CTSensor(CoordinatorEntity[OhmeChargeSessionsCoordinator], SensorEntity): def __init__( self, - coordinator: OhmeChargeSessionsCoordinator, + coordinator: OhmeAdvancedSettingsCoordinator, hass: HomeAssistant, client): super().__init__(coordinator=coordinator) @@ -215,7 +215,7 @@ class EnergyUsageSensor(CoordinatorEntity[OhmeStatisticsCoordinator], SensorEnti def __init__( self, - coordinator: OhmeChargeSessionsCoordinator, + coordinator: OhmeStatisticsCoordinator, hass: HomeAssistant, client): super().__init__(coordinator=coordinator) @@ -250,7 +250,7 @@ def native_value(self): return None -class NextSlotStartSensor(CoordinatorEntity[OhmeStatisticsCoordinator], SensorEntity): +class NextSlotStartSensor(CoordinatorEntity[OhmeChargeSessionsCoordinator], SensorEntity): """Sensor for next smart charge slot start time.""" _attr_name = "Next Charge Slot Start" _attr_device_class = SensorDeviceClass.TIMESTAMP @@ -302,7 +302,7 @@ def _handle_coordinator_update(self) -> None: self.async_write_ha_state() -class NextSlotEndSensor(CoordinatorEntity[OhmeStatisticsCoordinator], SensorEntity): +class NextSlotEndSensor(CoordinatorEntity[OhmeChargeSessionsCoordinator], SensorEntity): """Sensor for next smart charge slot end time.""" _attr_name = "Next Charge Slot End" _attr_device_class = SensorDeviceClass.TIMESTAMP diff --git a/custom_components/ohme/utils.py b/custom_components/ohme/utils.py index 7b7b21b..06eed23 100644 --- a/custom_components/ohme/utils.py +++ b/custom_components/ohme/utils.py @@ -3,14 +3,17 @@ import pytz -def charge_graph_next_slot(charge_start, points): - """Get the next charge slot from a list of graph points.""" - # Get start and current timestamp in seconds +def _format_charge_graph(charge_start, points): + """Convert relative time in points array to real timestamp (s).""" + charge_start = round(charge_start / 1000) - now = int(time()) + return [{"t": x["x"] + charge_start, "y": x["y"]} for x in points] + - # Replace relative timestamp (seconds) with real timestamp - data = [{"t": x["x"] + charge_start, "y": x["y"]} for x in points] +def charge_graph_next_slot(charge_start, points): + """Get the next charge slot start/end times from a list of graph points.""" + now = int(time()) + data = _format_charge_graph(charge_start, points) # Filter to points from now onwards data = [x for x in data if x["t"] > now] @@ -32,9 +35,10 @@ def charge_graph_next_slot(charge_start, points): if delta > 10 and not start_ts: # 1s added here as it otherwise often rounds down to xx:59:59 start_ts = data[idx]["t"] + 1 - elif start_ts and delta == 0: # If we have seen a start and see a delta of 0, this is the end + + # Take the first delta of 0 as the end + if delta == 0 and not end_ts: end_ts = data[idx]["t"] + 1 - break # These need to be presented with tzinfo or Home Assistant will reject them return { @@ -43,6 +47,24 @@ def charge_graph_next_slot(charge_start, points): } +def charge_graph_in_slot(charge_start, points): + """Are we currently in a charge slot?""" + now = int(time()) + data = _format_charge_graph(charge_start, points) + + # Loop through every value, skipping the last + for idx in range(0, len(data) - 1): + # This is our current point + if data[idx]["t"] < now and data[idx + 1]["t"] > now: + # If the delta line we are on is steeper than 10, + # we are in a charge slot. + if data[idx + 1]["y"] - data[idx]["y"] > 10: + return True + break + + return False + + def time_next_occurs(hour, minute): """Find when this time next occurs.""" current = datetime.now() From 4a735e03dda96ce8cb71fb56f104d7ba5f266387 Mon Sep 17 00:00:00 2001 From: Daniel Raper Date: Sat, 30 Dec 2023 18:36:28 +0000 Subject: [PATCH 09/10] Add testing for utility functions --- custom_components/ohme/utils.py | 8 ++--- tests/test_utils.py | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 tests/test_utils.py diff --git a/custom_components/ohme/utils.py b/custom_components/ohme/utils.py index 06eed23..17159fc 100644 --- a/custom_components/ohme/utils.py +++ b/custom_components/ohme/utils.py @@ -10,10 +10,10 @@ def _format_charge_graph(charge_start, points): return [{"t": x["x"] + charge_start, "y": x["y"]} for x in points] -def charge_graph_next_slot(charge_start, points): +def charge_graph_next_slot(charge_start, points, skip_format=False): """Get the next charge slot start/end times from a list of graph points.""" now = int(time()) - data = _format_charge_graph(charge_start, points) + data = points if skip_format else _format_charge_graph(charge_start, points) # Filter to points from now onwards data = [x for x in data if x["t"] > now] @@ -47,10 +47,10 @@ def charge_graph_next_slot(charge_start, points): } -def charge_graph_in_slot(charge_start, points): +def charge_graph_in_slot(charge_start, points, skip_format=False): """Are we currently in a charge slot?""" now = int(time()) - data = _format_charge_graph(charge_start, points) + data = points if skip_format else _format_charge_graph(charge_start, points) # Loop through every value, skipping the last for idx in range(0, len(data) - 1): diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..d81a212 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,59 @@ +"""Tests for the utils.""" +from unittest import mock +import random +from time import time + +from custom_components.ohme import utils + + +async def test_format_charge_graph(hass): + """Test that the _test_format_charge_graph function adds given timestamp / 1000 to each x coordinate.""" + TEST_DATA = [{"x": 10, "y": 0}, {"x": 20, "y": 0}, + {"x": 30, "y": 0}, {"x": 40, "y": 0}] + + start_time = random.randint(1577836800, 1764547200) # 2020-2025 + start_time_ms = start_time * 1000 + + result = utils._format_charge_graph(start_time_ms, TEST_DATA) + expected = [{"t": TEST_DATA[0]['x'] + start_time, "y": mock.ANY}, + {"t": TEST_DATA[1]['x'] + start_time, "y": mock.ANY}, + {"t": TEST_DATA[2]['x'] + start_time, "y": mock.ANY}, + {"t": TEST_DATA[3]['x'] + start_time, "y": mock.ANY}] + + assert expected == result + + +async def test_charge_graph_next_slot(hass): + """Test that we correctly work out when the next slot starts and ends.""" + start_time = int(time()) + TEST_DATA = [{"t": start_time - 100, "y": 0}, + {"t": start_time + 1000, "y": 0}, + {"t": start_time + 1600, "y": 1000}, + {"t": start_time + 1800, "y": 1000}] + + result = utils.charge_graph_next_slot(0, TEST_DATA, skip_format=True) + result = { + "start": result['start'].timestamp(), + "end": result['end'].timestamp(), + } + + expected = { + "start": start_time + 1001, + "end": start_time + 1601, + } + + assert expected == result + + +async def test_charge_graph_in_slot(hass): + """Test that we correctly intepret outselves as in a slot.""" + start_time = int(time()) + TEST_DATA = [{"t": start_time - 100, "y": 0}, + {"t": start_time - 10, "y": 0}, + {"t": start_time + 200, "y": 1000}, + {"t": start_time + 300, "y": 1000}] + + result = utils.charge_graph_in_slot(0, TEST_DATA, skip_format=True) + expected = True + + assert expected == result From 490243638b9923d0d2604e5380b9fc7aa5696df9 Mon Sep 17 00:00:00 2001 From: Daniel Raper Date: Sun, 31 Dec 2023 00:31:57 +0000 Subject: [PATCH 10/10] Added experimental charge state monitoring --- custom_components/ohme/binary_sensor.py | 74 +++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/custom_components/ohme/binary_sensor.py b/custom_components/ohme/binary_sensor.py index d8bad2b..92af83d 100644 --- a/custom_components/ohme/binary_sensor.py +++ b/custom_components/ohme/binary_sensor.py @@ -1,6 +1,6 @@ """Platform for sensor integration.""" from __future__ import annotations - +import logging from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity @@ -12,7 +12,9 @@ from .const import DOMAIN, DATA_COORDINATORS, COORDINATOR_CHARGESESSIONS, DATA_CLIENT from .coordinator import OhmeChargeSessionsCoordinator from .utils import charge_graph_in_slot +from time import time +_LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: core.HomeAssistant, @@ -97,6 +99,13 @@ def __init__( self._state = False self._client = client + # Cache the last power readings + self._last_reading = None + self._last_reading_in_slot = False + + # Allow a state override + self._override_until = None + self.entity_id = generate_entity_id( "binary_sensor.{}", "ohme_car_charging", hass=hass) @@ -115,13 +124,68 @@ def unique_id(self) -> str: @property def is_on(self) -> bool: - if self.coordinator.data and self.coordinator.data["power"]: - # Assume the car is actively charging if drawing over 0 watts - self._state = self.coordinator.data["power"]["watt"] > 0 + return self._state + + def _calculate_state(self) -> bool: + """Some trickery to get the charge state to update quickly.""" + # If we have overriden the state, return the current value until that time + if self._override_until and time() < self._override_until: + _LOGGER.debug("State overridden to False for 310s") + return self._state + + # We have passed override check, reset it + self._override_until = None + + power = self.coordinator.data["power"]["watt"] + + # No last reading to go off, use power draw based state only - this lags + if not self._last_reading: + _LOGGER.debug("Last reading not found, default to power > 0") + return power > 0 + + # Get power from last reading + lr_power = self._last_reading["power"]["watt"] + + # See if we are in a charge slot now and if we were for the last reading + in_charge_slot = charge_graph_in_slot( + self.coordinator.data['startTime'], self.coordinator.data['chargeGraph']['points']) + lr_in_charge_slot = self._last_reading_in_slot + + # Store this for next time + self._last_reading_in_slot = in_charge_slot + + # If: + # - Power has dropped by 40% since the last reading + # - Last reading we were in a charge slot + # - Now we are not in a charge slot + # The charge has stopped but the power reading is lagging. + if lr_power > 0 and power / lr_power < 0.6 and not in_charge_slot and lr_in_charge_slot: + _LOGGER.debug("Charge stop behaviour seen - overriding to False for 310 seconds") + self._override_until = time() + 310 # Override for 5 mins (and a bit) + return False + + # Its possible that this is the 'transitionary' reading - slots updated but not power + # Override _last_reading_in_slot and see what happens next time around + elif lr_power > 0 and not in_charge_slot and lr_in_charge_slot: + _LOGGER.debug("Possible transitionary reading. Treating as slot boundary in next tick.") + self._last_reading_in_slot = True + + # Fallback to the old way + return power > 0 + + @callback + def _handle_coordinator_update(self) -> None: + """Update data.""" + # If we have power info and the car is plugged in, calculate state. Otherwise, false + if self.coordinator.data and self.coordinator.data["power"] and self.coordinator.data['mode'] != "DISCONNECTED": + self._state = self._calculate_state() else: self._state = False - return self._state + self._last_reading = self.coordinator.data + self._last_updated = utcnow() + + self.async_write_ha_state() class PendingApprovalBinarySensor(