diff --git a/README.md b/README.md index 07f51a9..219094b 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ This integration exposes the following entities: * 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** +* Sensors (Charge power) - **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 @@ -54,6 +54,9 @@ This integration exposes the following entities: * 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 +* Inputs - **Only available during a charge session** + * Number: Target Percentage - Change the target percentage of the ongoing charge + * Time: Target Time - Change the time target for the current charge * Buttons * Approve Charge - Approves a charge when 'Pending Approval' is on @@ -67,6 +70,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 * OhmeAccountInfoCoordinator (1m refresh) * Switches: Lock buttons, require approval and sleep when inactive * OhmeAdvancedSettingsCoordinator (1m refresh) diff --git a/custom_components/ohme/__init__.py b/custom_components/ohme/__init__.py index 3d8fb7b..4891161 100644 --- a/custom_components/ohme/__init__.py +++ b/custom_components/ohme/__init__.py @@ -44,18 +44,11 @@ async def async_setup_entry(hass, entry): hass.data[DOMAIN][DATA_COORDINATORS] = coordinators # Create tasks for each entity type - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "sensor") - ) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "binary_sensor") - ) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "switch") - ) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "button") - ) + entity_types = ["sensor", "binary_sensor", "switch", "button", "number", "time"] + for entity_type in entity_types: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, entity_type) + ) return True diff --git a/custom_components/ohme/api_client.py b/custom_components/ohme/api_client.py index a1a5b32..1fbd66b 100644 --- a/custom_components/ohme/api_client.py +++ b/custom_components/ohme/api_client.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from homeassistant.helpers.entity import DeviceInfo from .const import DOMAIN +from .utils import time_next_occurs _LOGGER = logging.getLogger(__name__) @@ -36,6 +37,9 @@ def __init__(self, email, password): self._user_id = "" self._serial = "" + # Cache the last rule to use when we disable max charge or change schedule + self._last_rule = {} + # Sessions self._session = aiohttp.ClientSession( base_url="https://api.ohme.io") @@ -156,7 +160,7 @@ async def _get_request(self, url): 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]) @@ -189,9 +193,33 @@ 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_apply_charge_rule(self, max_price=False, target_ts=0, target_percent=100, pre_condition=False, pre_condition_length=0): + async def async_apply_charge_rule(self, max_price=None, target_time=None, target_percent=None, pre_condition=None, pre_condition_length=None): """Apply charge rule/stop max charge.""" + # Check every property. If we've provided it, use that. If not, use the existing. + if max_price is None: + max_price = self._last_rule['settings'][0]['enabled'] if 'settings' in self._last_rule and len( + self._last_rule['settings']) > 1 else False + + if target_percent is None: + target_percent = self._last_rule['targetPercent'] if 'targetPercent' in self._last_rule else 80 + + if pre_condition is None: + 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 + if target_time is None: + # Default to 9am + target_time = self._last_rule['targetTime'] if 'targetTime' in self._last_rule else 32400 + target_time = (target_time // 3600, + (target_time % 3600) // 60) + + target_ts = int(time_next_occurs( + target_time[0], target_time[1]).timestamp() * 1000) + + # Convert these to string form max_price = 'true' if max_price else 'false' pre_condition = 'true' if pre_condition else 'false' @@ -209,8 +237,13 @@ 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""" resp = await self._get_request('/v1/chargeSessions') + resp = resp[0] - return resp[0] + # Cache the current rule if we are given it + if resp["mode"] == "SMART_CHARGE" and 'appliedRule' in resp: + self._last_rule = resp["appliedRule"] + + return resp async def async_get_account_info(self): resp = await self._get_request('/v1/users/me/account') @@ -249,7 +282,7 @@ 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'] and resp['clampAmps'] > 0: self._ct_connected = True diff --git a/custom_components/ohme/number.py b/custom_components/ohme/number.py new file mode 100644 index 0000000..e740856 --- /dev/null +++ b/custom_components/ohme/number.py @@ -0,0 +1,74 @@ +from __future__ import annotations +import asyncio +from homeassistant.components.number import NumberEntity, NumberDeviceClass +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_ACCOUNTINFO + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities +): + """Setup switches and configure coordinator.""" + coordinators = hass.data[DOMAIN][DATA_COORDINATORS] + + coordinator = coordinators[COORDINATOR_CHARGESESSIONS] + client = hass.data[DOMAIN][DATA_CLIENT] + + numbers = [TargetPercentNumber(coordinator, hass, client)] + + async_add_entities(numbers, update_before_add=True) + + +class TargetPercentNumber(NumberEntity): + """Target percentage sensor.""" + _attr_name = "Target Percentage" + _attr_device_class = NumberDeviceClass.BATTERY + _attr_suggested_display_precision = 0 + + def __init__(self, coordinator, hass: HomeAssistant, client): + self.coordinator = coordinator + + self._client = client + + self._state = 0 + self._last_updated = None + self._attributes = {} + + self.entity_id = generate_entity_id( + "number.{}", "ohme_target_percent", 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("target_percent") + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + await self._client.async_apply_charge_rule(target_percent=int(value)) + + await asyncio.sleep(1) + await self.coordinator.async_refresh() + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:battery-heart" + + @property + def native_value(self): + """Get value from data returned from API by coordinator""" + if self.coordinator.data and self.coordinator.data['appliedRule']: + target = round( + self.coordinator.data['appliedRule']['targetPercent']) + + if target == 0: + return self._state + + self._state = target + return self._state + return None diff --git a/custom_components/ohme/switch.py b/custom_components/ohme/switch.py index afde853..7a37621 100644 --- a/custom_components/ohme/switch.py +++ b/custom_components/ohme/switch.py @@ -13,7 +13,6 @@ 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__) @@ -122,9 +121,6 @@ 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) @@ -149,10 +145,6 @@ 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() @@ -168,41 +160,8 @@ async def async_turn_on(self): async def async_turn_off(self): """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 - ) + We are not changing anything, just applying the last rule. No need to supply anything.""" + await self._client.async_apply_charge_rule() await asyncio.sleep(1) await self.coordinator.async_refresh() diff --git a/custom_components/ohme/time.py b/custom_components/ohme/time.py new file mode 100644 index 0000000..4577428 --- /dev/null +++ b/custom_components/ohme/time.py @@ -0,0 +1,74 @@ +from __future__ import annotations +import asyncio +import logging +from homeassistant.components.time import TimeEntity +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_ACCOUNTINFO +from datetime import time as dt_time + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities +): + """Setup switches and configure coordinator.""" + coordinators = hass.data[DOMAIN][DATA_COORDINATORS] + + coordinator = coordinators[COORDINATOR_CHARGESESSIONS] + client = hass.data[DOMAIN][DATA_CLIENT] + + numbers = [TargetTime(coordinator, hass, client)] + + async_add_entities(numbers, update_before_add=True) + + +class TargetTime(TimeEntity): + """Target time sensor.""" + _attr_name = "Target Time" + + def __init__(self, coordinator, hass: HomeAssistant, client): + self.coordinator = coordinator + + self._client = client + + self._state = 0 + self._last_updated = None + self._attributes = {} + + self.entity_id = generate_entity_id( + "number.{}", "ohme_target_time", 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("target_time") + + async def async_set_value(self, value: dt_time) -> None: + """Update the current value.""" + await self._client.async_apply_charge_rule(target_time=(int(value.hour), int(value.minute))) + + await asyncio.sleep(1) + await self.coordinator.async_refresh() + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:alarm-check" + + @property + def native_value(self): + """Get value from data returned from API by coordinator""" + if self.coordinator.data and self.coordinator.data['appliedRule']: + target = self.coordinator.data['appliedRule']['targetTime'] + return dt_time( + hour=target // 3600, + minute=(target % 3600) // 60, + second=0 + ) + return None