diff --git a/README.md b/README.md index d3b05c9..a835711 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) - 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 * 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..23625e5 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: @@ -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..e0a98b6 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 @@ -18,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 = "" @@ -63,8 +65,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 +115,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,9 +130,17 @@ async def async_get_charge_sessions(self, is_retry=False): return resp[0] + 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_device_info(self, is_retry=False): """Update _device_info with our charger model.""" - resp = await self._get_request('https://api.ohme.io/v1/users/me/account') + resp = await self.async_get_account_info() if not resp: return False @@ -138,12 +156,17 @@ async def async_update_device_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/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..3ecce3c 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,30 @@ 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)] - - async_add_entities(buttons, update_before_add=True) - - -class OhmePauseCharge(CoordinatorEntity[OhmeUpdateCoordinator], SwitchEntity): + 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(switches, update_before_add=True) + + +class OhmePauseChargeSwitch(CoordinatorEntity[OhmeChargeSessionsCoordinator], SwitchEntity): """Switch for pausing a charge.""" _attr_name = "Pause Charge" @@ -89,7 +103,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 +159,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()