From c69006963d58f4e31794ef998407ac181ae5a20b Mon Sep 17 00:00:00 2001 From: Daniel Raper Date: Thu, 28 Dec 2023 13:12:32 +0000 Subject: [PATCH 1/7] Add settings switches --- README.md | 10 ++-- custom_components/ohme/__init__.py | 14 +++-- custom_components/ohme/api_client.py | 25 ++++++-- custom_components/ohme/binary_sensor.py | 14 ++--- custom_components/ohme/const.py | 3 +- custom_components/ohme/coordinator.py | 25 +++++++- custom_components/ohme/sensor.py | 18 +++--- custom_components/ohme/switch.py | 77 ++++++++++++++++++++++--- 8 files changed, 147 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index d3b05c9..3a2cebf 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,6 @@ This is an unofficial integration. I have no affiliation with Ohme besides ownin This has only be tested with an Ohme Home Pro and does not currently support social login or accounts with multiple chargers. -It's still very early in development but I plan to add more sensors and support for pausing/resuming charge. - ## Entities This integration exposes the following entities: @@ -18,7 +16,11 @@ This integration exposes the following entities: * Power Draw (Watts) - Power draw of connected car * 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 -* Switches - These are only functional when a car is connected +* Switches (Settings) - These are only functional when a car is connected + * 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 * Max Charge - Forces the connected car to charge regardless of set schedule * Pause Charge - Pauses an ongoing charge @@ -36,4 +38,4 @@ This is the recommended installation method. 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. \ No newline at end of file +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/__init__.py b/custom_components/ohme/__init__.py index 798ba8e..0a3d31f 100644 --- a/custom_components/ohme/__init__.py +++ b/custom_components/ohme/__init__.py @@ -1,7 +1,7 @@ from homeassistant import core from .const import * from .api_client import OhmeApiClient -from .coordinator import OhmeUpdateCoordinator, OhmeStatisticsUpdateCoordinator +from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator, OhmeAccountInfoCoordinator async def async_setup(hass: core.HomeAssistant, config: dict) -> bool: @@ -15,7 +15,7 @@ async def async_setup_dependencies(hass, config): hass.data[DOMAIN][DATA_CLIENT] = client await client.async_refresh_session() - await client.async_update_device_info() + await client.async_update_account_info() async def async_setup_entry(hass, entry): @@ -31,13 +31,17 @@ async def async_setup_entry(hass, entry): await async_setup_dependencies(hass, config) - hass.data[DOMAIN][DATA_COORDINATOR] = OhmeUpdateCoordinator(hass=hass) - await hass.data[DOMAIN][DATA_COORDINATOR].async_config_entry_first_refresh() + hass.data[DOMAIN][DATA_CHARGESESSIONS_COORDINATOR] = OhmeChargeSessionsCoordinator(hass=hass) + await hass.data[DOMAIN][DATA_CHARGESESSIONS_COORDINATOR].async_config_entry_first_refresh() - hass.data[DOMAIN][DATA_STATISTICS_COORDINATOR] = OhmeStatisticsUpdateCoordinator( + hass.data[DOMAIN][DATA_STATISTICS_COORDINATOR] = OhmeStatisticsCoordinator( hass=hass) await hass.data[DOMAIN][DATA_STATISTICS_COORDINATOR].async_config_entry_first_refresh() + hass.data[DOMAIN][DATA_ACCOUNTINFO_COORDINATOR] = OhmeAccountInfoCoordinator( + hass=hass) + await hass.data[DOMAIN][DATA_ACCOUNTINFO_COORDINATOR].async_config_entry_first_refresh() + # Create tasks for each entity type hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, "sensor") diff --git a/custom_components/ohme/api_client.py b/custom_components/ohme/api_client.py index 6f8c735..578b345 100644 --- a/custom_components/ohme/api_client.py +++ b/custom_components/ohme/api_client.py @@ -1,5 +1,6 @@ import aiohttp import logging +import json from datetime import datetime, timedelta from homeassistant.helpers.entity import DeviceInfo from .const import DOMAIN @@ -63,8 +64,11 @@ async def _put_request(self, url, data=None, is_retry=False): If we get a non 200 response, refresh auth token and try again""" async with self._session.put( url, - data=data, - headers={"Authorization": "Firebase %s" % self._token} + data=json.dumps(data), + headers={ + "Authorization": "Firebase %s" % self._token, + "Content-Type": "application/json" + } ) as resp: if resp.status != 200 and not is_retry: await self.async_refresh_session() @@ -110,6 +114,11 @@ async def async_stop_max_charge(self): result = await self._put_request(f"https://api.ohme.io/v1/chargeSessions/{self._serial}/rule?enableMaxPrice=false&toPercent=80.0&inSeconds=43200") return bool(result) + async def async_set_configuration_value(self, values): + """Set a configuration value or values.""" + result = await self._put_request(f"https://api.ohme.io/v1/chargeDevices/{self._serial}/appSettings", data=values) + return bool(result) + 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""" @@ -120,10 +129,18 @@ async def async_get_charge_sessions(self, is_retry=False): return resp[0] - async def async_update_device_info(self, is_retry=False): - """Update _device_info with our charger model.""" + async def async_get_account_info(self): resp = await self._get_request('https://api.ohme.io/v1/users/me/account') + if not resp: + return False + + return resp + + async def async_update_account_info(self, is_retry=False): + """Update _device_info with our charger model.""" + resp = await self.async_get_account_info() + if not resp: return False diff --git a/custom_components/ohme/binary_sensor.py b/custom_components/ohme/binary_sensor.py index 71f26fb..1c213f8 100644 --- a/custom_components/ohme/binary_sensor.py +++ b/custom_components/ohme/binary_sensor.py @@ -8,8 +8,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import generate_entity_id -from .const import DOMAIN, DATA_COORDINATOR, DATA_CLIENT -from .coordinator import OhmeUpdateCoordinator +from .const import DOMAIN, DATA_CHARGESESSIONS_COORDINATOR, DATA_CLIENT +from .coordinator import OhmeChargeSessionsCoordinator async def async_setup_entry( @@ -19,7 +19,7 @@ async def async_setup_entry( ): """Setup sensors and configure coordinator.""" client = hass.data[DOMAIN][DATA_CLIENT] - coordinator = hass.data[DOMAIN][DATA_COORDINATOR] + coordinator = hass.data[DOMAIN][DATA_CHARGESESSIONS_COORDINATOR] sensors = [ConnectedSensor(coordinator, hass, client), ChargingSensor(coordinator, hass, client)] @@ -28,7 +28,7 @@ async def async_setup_entry( class ConnectedSensor( - CoordinatorEntity[OhmeUpdateCoordinator], + CoordinatorEntity[OhmeChargeSessionsCoordinator], BinarySensorEntity): """Binary sensor for if car is plugged in.""" @@ -37,7 +37,7 @@ class ConnectedSensor( def __init__( self, - coordinator: OhmeUpdateCoordinator, + coordinator: OhmeChargeSessionsCoordinator, hass: HomeAssistant, client): super().__init__(coordinator=coordinator) @@ -74,7 +74,7 @@ def is_on(self) -> bool: class ChargingSensor( - CoordinatorEntity[OhmeUpdateCoordinator], + CoordinatorEntity[OhmeChargeSessionsCoordinator], BinarySensorEntity): """Binary sensor for if car is charging.""" @@ -83,7 +83,7 @@ class ChargingSensor( def __init__( self, - coordinator: OhmeUpdateCoordinator, + coordinator: OhmeChargeSessionsCoordinator, hass: HomeAssistant, client): super().__init__(coordinator=coordinator) diff --git a/custom_components/ohme/const.py b/custom_components/ohme/const.py index f3bd275..352346b 100644 --- a/custom_components/ohme/const.py +++ b/custom_components/ohme/const.py @@ -2,5 +2,6 @@ DOMAIN = "ohme" DATA_CLIENT = "client" -DATA_COORDINATOR = "coordinator" +DATA_CHARGESESSIONS_COORDINATOR = "coordinator" DATA_STATISTICS_COORDINATOR = "statistics_coordinator" +DATA_ACCOUNTINFO_COORDINATOR = "accountinfo_coordinator" \ No newline at end of file diff --git a/custom_components/ohme/coordinator.py b/custom_components/ohme/coordinator.py index 37a1720..e54435a 100644 --- a/custom_components/ohme/coordinator.py +++ b/custom_components/ohme/coordinator.py @@ -11,7 +11,7 @@ _LOGGER = logging.getLogger(__name__) -class OhmeUpdateCoordinator(DataUpdateCoordinator): +class OhmeChargeSessionsCoordinator(DataUpdateCoordinator): """Coordinator to pull from API periodically.""" def __init__(self, hass): @@ -32,8 +32,29 @@ async def _async_update_data(self): except BaseException: raise UpdateFailed("Error communicating with API") +class OhmeAccountInfoCoordinator(DataUpdateCoordinator): + """Coordinator to pull from API periodically.""" + + def __init__(self, hass): + """Initialise coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Ohme Account Info", + update_interval=timedelta(minutes=1), + ) + self._client = hass.data[DOMAIN][DATA_CLIENT] + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + try: + return await self._client.async_get_account_info() + + except BaseException: + raise UpdateFailed("Error communicating with API") + -class OhmeStatisticsUpdateCoordinator(DataUpdateCoordinator): +class OhmeStatisticsCoordinator(DataUpdateCoordinator): """Coordinator to update statistics from API periodically. (But less so than OhmeUpdateCoordinator)""" diff --git a/custom_components/ohme/sensor.py b/custom_components/ohme/sensor.py index 071ffb8..fe94724 100644 --- a/custom_components/ohme/sensor.py +++ b/custom_components/ohme/sensor.py @@ -10,8 +10,8 @@ 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_COORDINATOR, DATA_STATISTICS_COORDINATOR -from .coordinator import OhmeUpdateCoordinator, OhmeStatisticsUpdateCoordinator +from .const import DOMAIN, DATA_CLIENT, DATA_CHARGESESSIONS_COORDINATOR, DATA_STATISTICS_COORDINATOR +from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator from .utils import charge_graph_next_slot @@ -22,7 +22,7 @@ async def async_setup_entry( ): """Setup sensors and configure coordinator.""" client = hass.data[DOMAIN][DATA_CLIENT] - coordinator = hass.data[DOMAIN][DATA_COORDINATOR] + coordinator = hass.data[DOMAIN][DATA_CHARGESESSIONS_COORDINATOR] stats_coordinator = hass.data[DOMAIN][DATA_STATISTICS_COORDINATOR] sensors = [PowerDrawSensor(coordinator, hass, client), EnergyUsageSensor( @@ -31,7 +31,7 @@ async def async_setup_entry( async_add_entities(sensors, update_before_add=True) -class PowerDrawSensor(CoordinatorEntity[OhmeUpdateCoordinator], SensorEntity): +class PowerDrawSensor(CoordinatorEntity[OhmeChargeSessionsCoordinator], SensorEntity): """Sensor for car power draw.""" _attr_name = "Current Power Draw" _attr_native_unit_of_measurement = UnitOfPower.WATT @@ -39,7 +39,7 @@ class PowerDrawSensor(CoordinatorEntity[OhmeUpdateCoordinator], SensorEntity): def __init__( self, - coordinator: OhmeUpdateCoordinator, + coordinator: OhmeChargeSessionsCoordinator, hass: HomeAssistant, client): super().__init__(coordinator=coordinator) @@ -73,7 +73,7 @@ def native_value(self): return 0 -class EnergyUsageSensor(CoordinatorEntity[OhmeStatisticsUpdateCoordinator], SensorEntity): +class EnergyUsageSensor(CoordinatorEntity[OhmeStatisticsCoordinator], SensorEntity): """Sensor for total energy usage.""" _attr_name = "Accumulative Energy Usage" _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR @@ -81,7 +81,7 @@ class EnergyUsageSensor(CoordinatorEntity[OhmeStatisticsUpdateCoordinator], Sens def __init__( self, - coordinator: OhmeUpdateCoordinator, + coordinator: OhmeChargeSessionsCoordinator, hass: HomeAssistant, client): super().__init__(coordinator=coordinator) @@ -116,14 +116,14 @@ def native_value(self): return None -class NextSlotSensor(CoordinatorEntity[OhmeStatisticsUpdateCoordinator], SensorEntity): +class NextSlotSensor(CoordinatorEntity[OhmeStatisticsCoordinator], SensorEntity): """Sensor for next smart charge slot.""" _attr_name = "Next Smart Charge Slot" _attr_device_class = SensorDeviceClass.TIMESTAMP def __init__( self, - coordinator: OhmeUpdateCoordinator, + coordinator: OhmeChargeSessionsCoordinator, hass: HomeAssistant, client): super().__init__(coordinator=coordinator) diff --git a/custom_components/ohme/switch.py b/custom_components/ohme/switch.py index 20a87bf..765c0d6 100644 --- a/custom_components/ohme/switch.py +++ b/custom_components/ohme/switch.py @@ -11,8 +11,8 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.util.dt import (utcnow) -from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATOR -from .coordinator import OhmeUpdateCoordinator +from .const import DOMAIN, DATA_CLIENT, DATA_CHARGESESSIONS_COORDINATOR, DATA_ACCOUNTINFO_COORDINATOR +from .coordinator import OhmeChargeSessionsCoordinator, OhmeAccountInfoCoordinator _LOGGER = logging.getLogger(__name__) @@ -23,16 +23,21 @@ async def async_setup_entry( async_add_entities ): """Setup switches and configure coordinator.""" - coordinator = hass.data[DOMAIN][DATA_COORDINATOR] + coordinator = hass.data[DOMAIN][DATA_CHARGESESSIONS_COORDINATOR] + accountinfo_coordinator = hass.data[DOMAIN][DATA_ACCOUNTINFO_COORDINATOR] client = hass.data[DOMAIN][DATA_CLIENT] - buttons = [OhmePauseCharge(coordinator, hass, client), - OhmeMaxCharge(coordinator, hass, client)] + buttons = [OhmePauseChargeSwitch(coordinator, hass, client), + OhmeMaxChargeSwitch(coordinator, hass, client), + OhmeConfigurationSwitch(accountinfo_coordinator, hass, client, "Lock Buttons", "lock", "buttonsLocked"), + OhmeConfigurationSwitch(accountinfo_coordinator, hass, client, "Require Approval", "check-decagram", "pluginsRequireApproval"), + OhmeConfigurationSwitch(accountinfo_coordinator, hass, client, "Sleep When Inactive", "power-sleep", "stealthEnabled") + ] async_add_entities(buttons, update_before_add=True) -class OhmePauseCharge(CoordinatorEntity[OhmeUpdateCoordinator], SwitchEntity): +class OhmePauseChargeSwitch(CoordinatorEntity[OhmeChargeSessionsCoordinator], SwitchEntity): """Switch for pausing a charge.""" _attr_name = "Pause Charge" @@ -89,7 +94,7 @@ async def async_turn_off(self): await self.coordinator.async_refresh() -class OhmeMaxCharge(CoordinatorEntity[OhmeUpdateCoordinator], SwitchEntity): +class OhmeMaxChargeSwitch(CoordinatorEntity[OhmeChargeSessionsCoordinator], SwitchEntity): """Switch for pausing a charge.""" _attr_name = "Max Charge" @@ -145,3 +150,61 @@ async def async_turn_off(self): await asyncio.sleep(1) await self.coordinator.async_refresh() + + +class OhmeConfigurationSwitch(CoordinatorEntity[OhmeAccountInfoCoordinator], SwitchEntity): + """Switch for changing configuration options.""" + + def __init__(self, coordinator, hass: HomeAssistant, client, name, icon, config_key): + super().__init__(coordinator=coordinator) + + self._client = client + + self._state = False + self._last_updated = None + self._attributes = {} + + self._icon = icon + self._attr_name = name + self._config_key = config_key + self.entity_id = generate_entity_id( + "switch.{}", "ohme_" + name.lower().replace(' ', '_'), 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(self._config_key) + + @property + def icon(self): + """Icon of the switch.""" + return f"mdi:{self._icon}" + + @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[self._config_key]) + + 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({ self._config_key: True }) + + 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({ self._config_key: False}) + + await asyncio.sleep(1) + await self.coordinator.async_refresh() From b9d087226d4e2e32fbb7ee40097c9be3e170ea6a Mon Sep 17 00:00:00 2001 From: Daniel Raper Date: Thu, 28 Dec 2023 13:19:51 +0000 Subject: [PATCH 2/7] Add capability check --- custom_components/ohme/__init__.py | 2 +- custom_components/ohme/api_client.py | 10 ++++++++-- custom_components/ohme/switch.py | 21 +++++++++++++++------ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/custom_components/ohme/__init__.py b/custom_components/ohme/__init__.py index 0a3d31f..23625e5 100644 --- a/custom_components/ohme/__init__.py +++ b/custom_components/ohme/__init__.py @@ -15,7 +15,7 @@ async def async_setup_dependencies(hass, config): hass.data[DOMAIN][DATA_CLIENT] = client await client.async_refresh_session() - await client.async_update_account_info() + await client.async_update_device_info() async def async_setup_entry(hass, entry): diff --git a/custom_components/ohme/api_client.py b/custom_components/ohme/api_client.py index 578b345..e0a98b6 100644 --- a/custom_components/ohme/api_client.py +++ b/custom_components/ohme/api_client.py @@ -19,6 +19,7 @@ def __init__(self, email, password): self._password = password self._device_info = None + self._capabilities = {} self._token = None self._user_id = "" self._serial = "" @@ -136,8 +137,8 @@ async def async_get_account_info(self): return False return resp - - async def async_update_account_info(self, is_retry=False): + + async def async_update_device_info(self, is_retry=False): """Update _device_info with our charger model.""" resp = await self.async_get_account_info() @@ -155,12 +156,17 @@ async def async_update_account_info(self, is_retry=False): serial_number=device['id'] ) + self._capabilities = device['modelCapabilities'] self._user_id = resp['user']['id'] self._serial = device['id'] self._device_info = info return True + def is_capable(self, capability): + """Return whether or not this model has a given capability.""" + return bool(self._capabilities[capability]) + def _last_second_of_month_timestamp(self): """Get the last second of this month.""" dt = datetime.today() diff --git a/custom_components/ohme/switch.py b/custom_components/ohme/switch.py index 765c0d6..3ecce3c 100644 --- a/custom_components/ohme/switch.py +++ b/custom_components/ohme/switch.py @@ -27,14 +27,23 @@ async def async_setup_entry( accountinfo_coordinator = hass.data[DOMAIN][DATA_ACCOUNTINFO_COORDINATOR] client = hass.data[DOMAIN][DATA_CLIENT] - buttons = [OhmePauseChargeSwitch(coordinator, hass, client), - OhmeMaxChargeSwitch(coordinator, hass, client), - OhmeConfigurationSwitch(accountinfo_coordinator, hass, client, "Lock Buttons", "lock", "buttonsLocked"), - OhmeConfigurationSwitch(accountinfo_coordinator, hass, client, "Require Approval", "check-decagram", "pluginsRequireApproval"), + switches = [OhmePauseChargeSwitch(coordinator, hass, client), + OhmeMaxChargeSwitch(coordinator, hass, client)] + + if client.is_capable("buttonsLockable"): + switches.append( + OhmeConfigurationSwitch(accountinfo_coordinator, hass, client, "Lock Buttons", "lock", "buttonsLocked") + ) + if client.is_capable("pluginsRequireApprovalMode"): + switches.append( + OhmeConfigurationSwitch(accountinfo_coordinator, hass, client, "Require Approval", "check-decagram", "pluginsRequireApproval") + ) + if client.is_capable("stealth"): + switches.append( OhmeConfigurationSwitch(accountinfo_coordinator, hass, client, "Sleep When Inactive", "power-sleep", "stealthEnabled") - ] + ) - async_add_entities(buttons, update_before_add=True) + async_add_entities(switches, update_before_add=True) class OhmePauseChargeSwitch(CoordinatorEntity[OhmeChargeSessionsCoordinator], SwitchEntity): From 665d2890124226b5dcf29c272febb3698ae5e0d5 Mon Sep 17 00:00:00 2001 From: Daniel Raper Date: Thu, 28 Dec 2023 13:21:47 +0000 Subject: [PATCH 3/7] Doc updates --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3a2cebf..187387b 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This integration exposes the following entities: * Power Draw (Watts) - Power draw of connected car * 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 -* Switches (Settings) - These are only functional when a car is connected +* Switches (Settings) * Lock Buttons - Locks buttons on charger * Require Approval - Require approval to start a charge * Sleep When Inactive - Charger screen & lights will automatically turn off From 30468bea9b51c825312c43a4a895b335b73542aa Mon Sep 17 00:00:00 2001 From: Daniel Raper Date: Thu, 28 Dec 2023 13:26:48 +0000 Subject: [PATCH 4/7] Doc updates --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 187387b..a835711 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This integration exposes the following entities: * Power Draw (Watts) - Power draw of connected car * 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 -* Switches (Settings) +* 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 From 48ee8eba246ad7ee7a2fc9e25ebc60d338102fea Mon Sep 17 00:00:00 2001 From: Daniel Raper Date: Thu, 28 Dec 2023 14:36:18 +0000 Subject: [PATCH 5/7] Added pending approval sensor and approve button --- custom_components/ohme/__init__.py | 6 ++- custom_components/ohme/api_client.py | 11 +++-- custom_components/ohme/binary_sensor.py | 49 +++++++++++++++++- custom_components/ohme/button.py | 66 +++++++++++++++++++++++++ custom_components/ohme/coordinator.py | 1 + custom_components/ohme/switch.py | 17 ++++--- 6 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 custom_components/ohme/button.py diff --git a/custom_components/ohme/__init__.py b/custom_components/ohme/__init__.py index 23625e5..4c2a9e0 100644 --- a/custom_components/ohme/__init__.py +++ b/custom_components/ohme/__init__.py @@ -31,7 +31,8 @@ async def async_setup_entry(hass, entry): await async_setup_dependencies(hass, config) - hass.data[DOMAIN][DATA_CHARGESESSIONS_COORDINATOR] = OhmeChargeSessionsCoordinator(hass=hass) + hass.data[DOMAIN][DATA_CHARGESESSIONS_COORDINATOR] = OhmeChargeSessionsCoordinator( + hass=hass) await hass.data[DOMAIN][DATA_CHARGESESSIONS_COORDINATOR].async_config_entry_first_refresh() hass.data[DOMAIN][DATA_STATISTICS_COORDINATOR] = OhmeStatisticsCoordinator( @@ -52,6 +53,9 @@ async def async_setup_entry(hass, entry): 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") + ) return True diff --git a/custom_components/ohme/api_client.py b/custom_components/ohme/api_client.py index e0a98b6..242f75f 100644 --- a/custom_components/ohme/api_client.py +++ b/custom_components/ohme/api_client.py @@ -69,7 +69,7 @@ async def _put_request(self, url, data=None, is_retry=False): headers={ "Authorization": "Firebase %s" % self._token, "Content-Type": "application/json" - } + } ) as resp: if resp.status != 200 and not is_retry: await self.async_refresh_session() @@ -104,6 +104,11 @@ async def async_resume_charge(self): result = await self._post_request(f"https://api.ohme.io/v1/chargeSessions/{self._serial}/resume", skip_json=True) return bool(result) + async def async_approve_charge(self): + """Approve a charge""" + result = await self._put_request(f"https://api.ohme.io/v1/chargeSessions/{self._serial}/approve?approve=true") + return bool(result) + async def async_max_charge(self): """Enable max charge""" result = await self._put_request(f"https://api.ohme.io/v1/chargeSessions/{self._serial}/rule?maxCharge=true") @@ -135,7 +140,7 @@ async def async_get_account_info(self): if not resp: return False - + return resp async def async_update_device_info(self, is_retry=False): @@ -166,7 +171,7 @@ async def async_update_device_info(self, is_retry=False): def is_capable(self, capability): """Return whether or not this model has a given capability.""" return bool(self._capabilities[capability]) - + def _last_second_of_month_timestamp(self): """Get the last second of this month.""" dt = datetime.today() diff --git a/custom_components/ohme/binary_sensor.py b/custom_components/ohme/binary_sensor.py index 1c213f8..85a1d33 100644 --- a/custom_components/ohme/binary_sensor.py +++ b/custom_components/ohme/binary_sensor.py @@ -22,7 +22,8 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][DATA_CHARGESESSIONS_COORDINATOR] sensors = [ConnectedSensor(coordinator, hass, client), - ChargingSensor(coordinator, hass, client)] + ChargingSensor(coordinator, hass, client), + PendingApprovalSensor(coordinator, hass, client)] async_add_entities(sensors, update_before_add=True) @@ -118,3 +119,49 @@ def is_on(self) -> bool: self._state = False return self._state + + +class PendingApprovalSensor( + CoordinatorEntity[OhmeChargeSessionsCoordinator], + BinarySensorEntity): + """Binary sensor for if a charge is pending approval.""" + + _attr_name = "Pending Approval" + + 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_pending_approval", hass=hass) + + self._attr_device_info = hass.data[DOMAIN][DATA_CLIENT].get_device_info( + ) + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:alert-decagram" + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return self._client.get_unique_id("pending_approval") + + @property + def is_on(self) -> bool: + if self.coordinator.data is None: + self._state = False + else: + self._state = bool( + self.coordinator.data["mode"] == "PENDING_APPROVAL") + + return self._state diff --git a/custom_components/ohme/button.py b/custom_components/ohme/button.py new file mode 100644 index 0000000..60f7302 --- /dev/null +++ b/custom_components/ohme/button.py @@ -0,0 +1,66 @@ +from __future__ import annotations +import logging +import asyncio + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import generate_entity_id +from homeassistant.components.button import ButtonEntity + +from .const import DOMAIN, DATA_CLIENT, DATA_CHARGESESSIONS_COORDINATOR +from .coordinator import OhmeChargeSessionsCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities +): + """Setup switches.""" + client = hass.data[DOMAIN][DATA_CLIENT] + coordinator = hass.data[DOMAIN][DATA_CHARGESESSIONS_COORDINATOR] + + buttons = [] + + if client.is_capable("pluginsRequireApprovalMode"): + buttons.append( + OhmeApproveChargeButton(coordinator, hass, client) + ) + + async_add_entities(buttons, update_before_add=True) + + +class OhmeApproveChargeButton(ButtonEntity): + """Button for approving a charge.""" + _attr_name = "Approve Charge" + + def __init__(self, coordinator: OhmeChargeSessionsCoordinator, hass: HomeAssistant, client): + self._client = client + self._coordinator = coordinator + + self._state = False + self._last_updated = None + self._attributes = {} + + self.entity_id = generate_entity_id( + "switch.{}", "ohme_approve_charge", 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("approve_charge") + + @property + def icon(self): + """Icon of the switch.""" + return "mdi:check-decagram-outline" + + async def async_press(self): + """Approve the charge.""" + await self._client.async_approve_charge() + + await asyncio.sleep(1) + await self._coordinator.async_refresh() diff --git a/custom_components/ohme/coordinator.py b/custom_components/ohme/coordinator.py index e54435a..2f280d5 100644 --- a/custom_components/ohme/coordinator.py +++ b/custom_components/ohme/coordinator.py @@ -32,6 +32,7 @@ async def _async_update_data(self): except BaseException: raise UpdateFailed("Error communicating with API") + class OhmeAccountInfoCoordinator(DataUpdateCoordinator): """Coordinator to pull from API periodically.""" diff --git a/custom_components/ohme/switch.py b/custom_components/ohme/switch.py index 3ecce3c..67b9373 100644 --- a/custom_components/ohme/switch.py +++ b/custom_components/ohme/switch.py @@ -28,19 +28,22 @@ async def async_setup_entry( client = hass.data[DOMAIN][DATA_CLIENT] switches = [OhmePauseChargeSwitch(coordinator, hass, client), - OhmeMaxChargeSwitch(coordinator, hass, client)] - + OhmeMaxChargeSwitch(coordinator, hass, client)] + if client.is_capable("buttonsLockable"): switches.append( - OhmeConfigurationSwitch(accountinfo_coordinator, hass, client, "Lock Buttons", "lock", "buttonsLocked") + OhmeConfigurationSwitch( + accountinfo_coordinator, hass, client, "Lock Buttons", "lock", "buttonsLocked") ) if client.is_capable("pluginsRequireApprovalMode"): switches.append( - OhmeConfigurationSwitch(accountinfo_coordinator, hass, client, "Require Approval", "check-decagram", "pluginsRequireApproval") + OhmeConfigurationSwitch(accountinfo_coordinator, hass, client, + "Require Approval", "check-decagram", "pluginsRequireApproval") ) if client.is_capable("stealth"): switches.append( - OhmeConfigurationSwitch(accountinfo_coordinator, hass, client, "Sleep When Inactive", "power-sleep", "stealthEnabled") + OhmeConfigurationSwitch(accountinfo_coordinator, hass, client, + "Sleep When Inactive", "power-sleep", "stealthEnabled") ) async_add_entities(switches, update_before_add=True) @@ -206,14 +209,14 @@ def _handle_coordinator_update(self) -> None: async def async_turn_on(self): """Turn on the switch.""" - await self._client.async_set_configuration_value({ self._config_key: True }) + await self._client.async_set_configuration_value({self._config_key: True}) 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({ self._config_key: False}) + await self._client.async_set_configuration_value({self._config_key: False}) await asyncio.sleep(1) await self.coordinator.async_refresh() From 616716ce93f33ff5efd490de5bc197807fb35ec0 Mon Sep 17 00:00:00 2001 From: Daniel Raper Date: Thu, 28 Dec 2023 14:37:06 +0000 Subject: [PATCH 6/7] Doc changes --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index a835711..48c6ff0 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This integration exposes the following entities: * Binary Sensors * 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 * Power Draw (Watts) - Power draw of connected car * Accumulative Energy Usage (kWh) - Total energy used by the charger @@ -23,6 +24,8 @@ 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 +* Buttons + * Approve Charge - Approves a charge when 'Pending Approval' is on ## Installation From 3163079e26d3e3b9fc937baa35b6b5fe66ab571d Mon Sep 17 00:00:00 2001 From: Daniel Raper Date: Thu, 28 Dec 2023 15:31:04 +0000 Subject: [PATCH 7/7] Add license --- LICENSE | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b9f4ada --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2023 Daniel Raper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +