diff --git a/README.md b/README.md index 4139476..57b4660 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,10 @@ If you find any bugs or would like to request a feature, please open an issue. ## Tested Hardware This integration has been tested with the following hardware: -* Ohme Home Pro [v1.32] -* Ohme Home [v1.32] -* Ohme Go [v1.32] -* Ohme ePod [v2.12] +* Ohme Home Pro +* Ohme Home +* Ohme Go +* Ohme ePod ## External Software The 'Charge Slot Active' binary sensor mimics the `planned_dispatches` and `completed_dispatches` attributes from the [Octopus Energy](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy) integration, so should support external software which reads this such as [predbat](https://springfall2008.github.io/batpred/devices/#ohme). @@ -57,7 +57,6 @@ This integration exposes the following entities: * Sensors (Other) * CT Reading (Amps) - Reading from attached CT clamp * Energy Usage (kWh) - Energy used in the current/last session. *This is supported by the energy dashboard.* - * Accumulative Energy Usage (kWh) - Deprecated - Total energy used by the charger (If enabled in options) * Battery State of Charge (%) - If your car is API connected this is read from the car, if not it is how much charge Ohme thinks it has added * Switches (Settings) - **Only options available to your charger model will show** * Lock Buttons - Locks buttons on charger @@ -82,7 +81,7 @@ This integration exposes the following entities: Some options can be set from the 'Configure' menu in Home Assistant: * Never update an ongoing session - Override the default behaviour of the target time, percentage and preconditioning inputs and only ever update the schedule, not the current session. This was added as changing the current session can cause issues for customers on Intelligent Octopus Go. * Don't collapse charge slots - By default, adjacent slots are merged into one. This option shows every slot, as shown in the Ohme app. -* Enable accumulative energy usage sensor - Enable the sensor showing an all-time incrementing energy usage counter. This causes issues with some accounts. +* Refresh Intervals - The refresh interval for the four coordinators listed below can be configured manually. The default times also serve as minimums, as to be respectful to Ohme, but you can choose to fetch data less frequently. ## Coordinators @@ -102,7 +101,5 @@ The coordinators are listed with their refresh intervals below. Relevant coordin * OhmeAdvancedSettingsCoordinator (1m refresh) * Sensors: CT reading sensor * Binary Sensors: Charger online -* OhmeStatisticsCoordinator (30m refresh) - * Sensors: Accumulative energy usage * OhmeChargeSchedulesCoordinator (10m refresh) * Inputs: Target time, target percentage and preconditioning (If car disconnected) diff --git a/custom_components/ohme/__init__.py b/custom_components/ohme/__init__.py index 852d6ed..c354e94 100644 --- a/custom_components/ohme/__init__.py +++ b/custom_components/ohme/__init__.py @@ -3,7 +3,7 @@ from .const import * from .utils import get_option from .api_client import OhmeApiClient -from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator, OhmeAccountInfoCoordinator, OhmeAdvancedSettingsCoordinator, OhmeChargeSchedulesCoordinator +from .coordinator import OhmeChargeSessionsCoordinator, OhmeAccountInfoCoordinator, OhmeAdvancedSettingsCoordinator, OhmeChargeSchedulesCoordinator from homeassistant.exceptions import ConfigEntryNotReady _LOGGER = logging.getLogger(__name__) @@ -40,35 +40,16 @@ async def async_setup_entry(hass, entry): coordinators = [ OhmeChargeSessionsCoordinator(hass=hass), # COORDINATOR_CHARGESESSIONS OhmeAccountInfoCoordinator(hass=hass), # COORDINATOR_ACCOUNTINFO - OhmeStatisticsCoordinator(hass=hass), # COORDINATOR_STATISTICS OhmeAdvancedSettingsCoordinator(hass=hass), # COORDINATOR_ADVANCED OhmeChargeSchedulesCoordinator(hass=hass) # COORDINATOR_SCHEDULES ] # We can function without these so setup can continue coordinators_optional = [ - OhmeStatisticsCoordinator, OhmeAdvancedSettingsCoordinator ] - coordinators_skipped = [] - - # Skip statistics coordinator if we don't need it - if not get_option(hass, "enable_accumulative_energy"): - coordinators_skipped.append(OhmeStatisticsCoordinator) - for coordinator in coordinators: - # If we should skip this coordinator - skip = False - for skipped in coordinators_skipped: - if isinstance(coordinator, skipped): - skip = True - break - - if skip: - _LOGGER.debug(f"Skipping initial load of {coordinator.__class__.__name__}") - continue - # Catch failures if this is an 'optional' coordinator try: await coordinator.async_config_entry_first_refresh() diff --git a/custom_components/ohme/api_client.py b/custom_components/ohme/api_client.py index 5861d72..211cc52 100644 --- a/custom_components/ohme/api_client.py +++ b/custom_components/ohme/api_client.py @@ -95,14 +95,6 @@ async def async_refresh_session(self): # Internal methods - def _last_second_of_month_timestamp(self): - """Get the last second of this month.""" - dt = datetime.today() - dt = dt.replace(day=1) + timedelta(days=32) - dt = dt.replace(day=1, hour=0, minute=0, second=0, - microsecond=0) - timedelta(seconds=1) - return int(dt.timestamp()*1e3) - async def _handle_api_error(self, url, resp): """Raise an exception if API response failed.""" if resp.status != 200: @@ -335,13 +327,6 @@ async def async_update_device_info(self, is_retry=False): return True - async def async_get_charge_statistics(self): - """Get charge statistics. Currently this is just for all time (well, Jan 2019).""" - end_ts = self._last_second_of_month_timestamp() - resp = await self._get_request(f"/v1/chargeSessions/summary/users/{self._user_id}?&startTs={self._provision_date}&endTs={end_ts}&granularity=MONTH") - - return resp['totalStats'] - async def async_get_advanced_settings(self): """Get advanced settings (mainly for CT clamp reading)""" resp = await self._get_request(f"/v1/chargeDevices/{self._serial}/advancedSettings") diff --git a/custom_components/ohme/config_flow.py b/custom_components/ohme/config_flow.py index 1c19902..3381658 100644 --- a/custom_components/ohme/config_flow.py +++ b/custom_components/ohme/config_flow.py @@ -1,6 +1,6 @@ import voluptuous as vol from homeassistant.config_entries import (ConfigFlow, OptionsFlow) -from .const import DOMAIN, CONFIG_VERSION +from .const import DOMAIN, CONFIG_VERSION, DEFAULT_INTERVAL_CHARGESESSIONS, DEFAULT_INTERVAL_ACCOUNTINFO, DEFAULT_INTERVAL_ADVANCED, DEFAULT_INTERVAL_SCHEDULES from .api_client import OhmeApiClient @@ -91,7 +91,16 @@ async def async_step_init(self, options): "never_collapse_slots", default=self._config_entry.options.get("never_collapse_slots", False) ) : bool, vol.Required( - "enable_accumulative_energy", default=self._config_entry.options.get("enable_accumulative_energy", False) - ) : bool + "interval_chargesessions", default=self._config_entry.options.get("interval_chargesessions", DEFAULT_INTERVAL_CHARGESESSIONS) + ) : vol.All(vol.Coerce(float), vol.Clamp(min=DEFAULT_INTERVAL_CHARGESESSIONS)), + vol.Required( + "interval_accountinfo", default=self._config_entry.options.get("interval_accountinfo", DEFAULT_INTERVAL_ACCOUNTINFO) + ) : vol.All(vol.Coerce(float), vol.Clamp(min=DEFAULT_INTERVAL_ACCOUNTINFO)), + vol.Required( + "interval_advanced", default=self._config_entry.options.get("interval_advanced", DEFAULT_INTERVAL_ADVANCED) + ) : vol.All(vol.Coerce(float), vol.Clamp(min=DEFAULT_INTERVAL_ADVANCED)), + vol.Required( + "interval_schedules", default=self._config_entry.options.get("interval_schedules", DEFAULT_INTERVAL_SCHEDULES) + ) : vol.All(vol.Coerce(float), vol.Clamp(min=DEFAULT_INTERVAL_SCHEDULES)) }), errors=errors ) diff --git a/custom_components/ohme/const.py b/custom_components/ohme/const.py index 4016b88..ec6fbb5 100644 --- a/custom_components/ohme/const.py +++ b/custom_components/ohme/const.py @@ -1,7 +1,7 @@ """Component constants""" DOMAIN = "ohme" USER_AGENT = "dan-r-homeassistant-ohme" -INTEGRATION_VERSION = "1.0.0" +INTEGRATION_VERSION = "1.0.1" CONFIG_VERSION = 1 ENTITY_TYPES = ["sensor", "binary_sensor", "switch", "button", "number", "time"] @@ -12,6 +12,10 @@ COORDINATOR_CHARGESESSIONS = 0 COORDINATOR_ACCOUNTINFO = 1 -COORDINATOR_STATISTICS = 2 -COORDINATOR_ADVANCED = 3 -COORDINATOR_SCHEDULES = 4 \ No newline at end of file +COORDINATOR_ADVANCED = 2 +COORDINATOR_SCHEDULES = 3 + +DEFAULT_INTERVAL_CHARGESESSIONS = 0.5 +DEFAULT_INTERVAL_ACCOUNTINFO = 1 +DEFAULT_INTERVAL_ADVANCED = 1 +DEFAULT_INTERVAL_SCHEDULES = 10 diff --git a/custom_components/ohme/coordinator.py b/custom_components/ohme/coordinator.py index 253ebde..ddb8b9d 100644 --- a/custom_components/ohme/coordinator.py +++ b/custom_components/ohme/coordinator.py @@ -6,7 +6,8 @@ UpdateFailed ) -from .const import DOMAIN, DATA_CLIENT +from .const import DOMAIN, DATA_CLIENT, DEFAULT_INTERVAL_CHARGESESSIONS, DEFAULT_INTERVAL_ACCOUNTINFO, DEFAULT_INTERVAL_ADVANCED, DEFAULT_INTERVAL_SCHEDULES +from .utils import get_option _LOGGER = logging.getLogger(__name__) @@ -20,7 +21,9 @@ def __init__(self, hass): hass, _LOGGER, name="Ohme Charge Sessions", - update_interval=timedelta(seconds=30), + update_interval=timedelta(minutes= + get_option(hass, "interval_chargesessions", DEFAULT_INTERVAL_CHARGESESSIONS) + ), ) self._client = hass.data[DOMAIN][DATA_CLIENT] @@ -42,7 +45,9 @@ def __init__(self, hass): hass, _LOGGER, name="Ohme Account Info", - update_interval=timedelta(minutes=1), + update_interval=timedelta(minutes= + get_option(hass, "interval_accountinfo", DEFAULT_INTERVAL_ACCOUNTINFO) + ), ) self._client = hass.data[DOMAIN][DATA_CLIENT] @@ -55,28 +60,6 @@ async def _async_update_data(self): raise UpdateFailed("Error communicating with API") -class OhmeStatisticsCoordinator(DataUpdateCoordinator): - """Coordinator to update statistics from API periodically. - (But less so than the others)""" - - def __init__(self, hass): - """Initialise coordinator.""" - super().__init__( - hass, - _LOGGER, - name="Ohme Charger Statistics", - update_interval=timedelta(minutes=30), - ) - 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_charge_statistics() - - except BaseException: - raise UpdateFailed("Error communicating with API") - class OhmeAdvancedSettingsCoordinator(DataUpdateCoordinator): """Coordinator to pull CT clamp reading.""" @@ -86,7 +69,9 @@ def __init__(self, hass): hass, _LOGGER, name="Ohme Advanced Settings", - update_interval=timedelta(minutes=1), + update_interval=timedelta(minutes= + get_option(hass, "interval_advanced", DEFAULT_INTERVAL_ADVANCED) + ), ) self._client = hass.data[DOMAIN][DATA_CLIENT] @@ -98,6 +83,7 @@ async def _async_update_data(self): except BaseException: raise UpdateFailed("Error communicating with API") + class OhmeChargeSchedulesCoordinator(DataUpdateCoordinator): """Coordinator to pull charge schedules.""" @@ -107,7 +93,9 @@ def __init__(self, hass): hass, _LOGGER, name="Ohme Charge Schedules", - update_interval=timedelta(minutes=10), + update_interval=timedelta(minutes= + get_option(hass, "interval_schedules", DEFAULT_INTERVAL_SCHEDULES) + ), ) self._client = hass.data[DOMAIN][DATA_CLIENT] @@ -118,4 +106,3 @@ async def _async_update_data(self): except BaseException: raise UpdateFailed("Error communicating with API") - diff --git a/custom_components/ohme/sensor.py b/custom_components/ohme/sensor.py index e397a3e..2ad81ff 100644 --- a/custom_components/ohme/sensor.py +++ b/custom_components/ohme/sensor.py @@ -12,8 +12,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_COORDINATORS, DATA_SLOTS, COORDINATOR_CHARGESESSIONS, COORDINATOR_STATISTICS, COORDINATOR_ADVANCED -from .coordinator import OhmeChargeSessionsCoordinator, OhmeStatisticsCoordinator, OhmeAdvancedSettingsCoordinator +from .const import DOMAIN, DATA_CLIENT, DATA_COORDINATORS, DATA_SLOTS, COORDINATOR_CHARGESESSIONS, COORDINATOR_ADVANCED +from .coordinator import OhmeChargeSessionsCoordinator, OhmeAdvancedSettingsCoordinator from .utils import next_slot, get_option, slot_list, slot_list_str _LOGGER = logging.getLogger(__name__) @@ -28,7 +28,6 @@ async def async_setup_entry( coordinators = hass.data[DOMAIN][DATA_COORDINATORS] coordinator = coordinators[COORDINATOR_CHARGESESSIONS] - stats_coordinator = coordinators[COORDINATOR_STATISTICS] adv_coordinator = coordinators[COORDINATOR_ADVANCED] sensors = [PowerDrawSensor(coordinator, hass, client), @@ -41,9 +40,6 @@ async def async_setup_entry( SlotListSensor(coordinator, hass, client), BatterySOCSensor(coordinator, hass, client)] - if get_option(hass, "enable_accumulative_energy"): - sensors.append(AccumulativeEnergyUsageSensor(stats_coordinator, hass, client)) - async_add_entities(sensors, update_before_add=True) @@ -213,52 +209,6 @@ def native_value(self): return self.coordinator.data['clampAmps'] -class AccumulativeEnergyUsageSensor(CoordinatorEntity[OhmeStatisticsCoordinator], SensorEntity): - """Sensor for total energy usage.""" - _attr_name = "Accumulative Energy Usage" - _attr_native_unit_of_measurement = UnitOfEnergy.WATT_HOUR - _attr_suggested_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR - _attr_suggested_display_precision = 1 - _attr_device_class = SensorDeviceClass.ENERGY - _attr_state_class = SensorStateClass.TOTAL - - def __init__( - self, - coordinator: OhmeStatisticsCoordinator, - 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_accumulative_energy", 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("accumulative_energy") - - @property - def icon(self): - """Icon of the sensor.""" - return "mdi:lightning-bolt" - - @property - def native_value(self): - """Get value from data returned from API by coordinator""" - if self.coordinator.data and self.coordinator.data['energyChargedTotalWh']: - return self.coordinator.data['energyChargedTotalWh'] - - return None - - class EnergyUsageSensor(CoordinatorEntity[OhmeChargeSessionsCoordinator], SensorEntity): """Sensor for total energy usage.""" _attr_name = "Energy" @@ -290,13 +240,25 @@ def __init__( def _handle_coordinator_update(self) -> None: # Ensure we have data, then ensure value is going up and above 0 if self.coordinator.data and self.coordinator.data['batterySoc']: - new_state = self.coordinator.data['batterySoc']['wh'] + new_state = 0 + try: + new_state = self.coordinator.data['chargeGraph']['now']['y'] + except BaseException: + _LOGGER.debug("EnergyUsageSensor: ChargeGraph reading failed, falling back to batterySoc") + new_state = self.coordinator.data['batterySoc']['wh'] # Let the state reset to 0, but not drop otherwise if not new_state or new_state <= 0: + _LOGGER.debug("EnergyUsageSensor: Resetting Wh reading to 0") self._state = 0 else: - self._state = max(0, self._state or 0, new_state) + # Allow a significant (90%+) drop, even if we dont hit exactly 0 + if self._state and self._state > 0 and new_state > 0 and (new_state / self._state) < 0.1: + self._state = new_state + else: + self._state = max(0, self._state or 0, new_state) + + _LOGGER.debug("EnergyUsageSensor: New state is %s", self._state) self.async_write_ha_state() diff --git a/custom_components/ohme/translations/en.json b/custom_components/ohme/translations/en.json index 4c397d8..a3c8001 100644 --- a/custom_components/ohme/translations/en.json +++ b/custom_components/ohme/translations/en.json @@ -24,12 +24,16 @@ "password": "Password", "never_session_specific": "Never update an ongoing session", "never_collapse_slots": "Don't collapse charge slots", - "enable_accumulative_energy": "Enable accumulative energy sensor" + "interval_chargesessions": "Charge sessions refresh rate (minutes)", + "interval_accountinfo": "Account info refresh rate (minutes)", + "interval_advanced": "Advanced settings refresh rate (minutes)", + "interval_schedules": "Schedules refresh rate (minutes)" }, "data_description": { "password": "If you are not changing your credentials, leave the password field empty.", "never_session_specific": "When adjusting charge percentage, charge target or preconditioning settings, the schedule will always be updated even if a charge session is in progress.", - "never_collapse_slots": "By default, adjacent slots are merged into one. This option shows every slot, as shown in the Ohme app." + "never_collapse_slots": "By default, adjacent slots are merged into one. This option shows every slot, as shown in the Ohme app.", + "interval_schedules": "Details on which entities are updated by each coordinator are in the README." } } },