diff --git a/README.md b/README.md index b5f559d..363ae3a 100644 --- a/README.md +++ b/README.md @@ -16,20 +16,49 @@ The Aurora+ integration adds support for retriving data from the Aurora+ API suc - NumberOfUnpaidBills - BillOverDueAmount +It also uses https://github.com/ldotlopez/ha-historical-sensor/ to fetch hourly +usage from the previous day, and make it available for the Energy dashboard: + +- Dollar Value Usage (Total and per-Tariff) +- Kilowatt Hour Usage (Total and per-Tariff) + Note: To use the Aurora+ integration you need a valid account with Aurora. ## Configuration -Using *YAML*: add `auroraplus` platform to your sensor configuration in `configuration.yaml`. Example: - -```yaml -# Example configuration.yaml entry -sensor: - - platform: auroraplus - name: "Power Sensor" - username: username@email.com - password: Password - scan_interval: - hours: 2 - rounding: 1 -``` -Note: Name, scan_interval and rounding are optional. If scan_interval is not set a default value of 1 hours will be used. If rounding is not set a default value of 2 will be used. Most Aurora+ data is updated daily. + +This integration uses Home Assistant's config flow. Simply go to `Settings` / +`Devices & Services`, choose `Add Integration`, and search for `Aurora+`. + +In the configuration dialog, you need to input an OAuth access key, which allows +access to your account's data without MFA. Authentication and API access is done +via https://github.com/shtrom/AuroraPlus/tree/oauth-mfa-token, which you can +also use to obtain the access token. + +On any machine able to run Python (not necessarily your Home Assistant server), +install the AuroraPlus Python module from the URL above. You can then follow the +instructions at +https://github.com/shtrom/AuroraPlus/tree/oauth-mfa-token?tab=readme-ov-file#obtain-a-token. + +Essentially, just run + + aurora_get_token + +and follow the instructions (open link, enter MFA, copy URL of error page back). + +## CAVEATs + +1. The access_token seems to expire every 29 days. You'll have to redo this dance + every month to keep being able to access the data. A notification will be + issued when this is needed. + +2. Upon adding the integration, only sensors with readings on the previous day + will be available to add to the energy dashboard. This could be a problem if + the previous day was a full-day off-peak day, as the peak tariff won't show + up. Simply restart Home Assistant on a day after the missing tariff was used + for a sensor to be created. + +3. Upon reauthenticating, a bunch of SQLAlchemyError will prop up in the logs. + They are currently believed to be harmless, and stop happening after a + restart. + +4. While this should support multiple services at once, this hasn't been tested. diff --git a/custom_components/auroraplus/__init__.py b/custom_components/auroraplus/__init__.py index f5e407e..be3e38c 100644 --- a/custom_components/auroraplus/__init__.py +++ b/custom_components/auroraplus/__init__.py @@ -1 +1,50 @@ -"""The auroraplus sensor integration.""" \ No newline at end of file +"""The auroraplus sensor integration.""" +import logging + +from homeassistant.components.sensor.const import ( + SensorDeviceClass, +) +from homeassistant.const import ( + CONF_USERNAME, + CONF_PASSWORD, + CONF_ACCESS_TOKEN, + CONF_NAME, + CONF_MONITORED_CONDITIONS, + CURRENCY_DOLLAR, + CONF_SCAN_INTERVAL, + UnitOfEnergy, +) + +from .api import AuroraApi, aurora_init +from .const import DOMAIN +# from .sensor import async_setup_platform +from .sensor import async_setup_entry + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass, entry): + """Set up entry.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = dict(entry.data) + + access_token = entry.data.get(CONF_ACCESS_TOKEN) + + try: + api_session = await hass.async_add_executor_job( + aurora_init, + access_token + ) + except OSError as err: + raise PlatformNotReady('Connection to Aurora+ failed') from err + + entry.async_on_unload( + entry.add_update_listener(AuroraApi.update_listener) + ) + + entry.runtime_data = AuroraApi(hass, api_session) + + await hass.config_entries.async_forward_entry_setup( + entry, "sensor" + ) + + return True diff --git a/custom_components/auroraplus/api.py b/custom_components/auroraplus/api.py new file mode 100644 index 0000000..9c8bcc4 --- /dev/null +++ b/custom_components/auroraplus/api.py @@ -0,0 +1,96 @@ +import logging + +import auroraplus +from requests.exceptions import HTTPError + +from homeassistant.const import ( + CONF_ACCESS_TOKEN, +) +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.util import Throttle + +from .const import ( + CONF_ROUNDING, + CONF_SERVICE_AGREEMENT_ID, + DEFAULT_SCAN_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + +def aurora_init(access_token: str): + try: + session = auroraplus.api(None, access_token) + session.getmonth() + except HTTPError as e: + status_code = e.response.status_code + if status_code in [401, 403]: + raise ConfigEntryAuthFailed(e) from e + raise e + return session + +class AuroraApi(): + """Asynchronously-updating wrapper for the Aurora API. """ + _hass = None + _session = None + + _instances = {} + + def __init__(self, hass, session): + self._hass = hass + self._session = session + self.service_agreement_id = session.serviceAgreementID + self.service_address = session.month['ServiceAgreements'][session.serviceAgreementID]['PremiseName'] + self.__class__._instances[self.service_agreement_id] = self + _LOGGER.debug(f'AuroraApi ready with {self._session}') + + @Throttle(min_time=DEFAULT_SCAN_INTERVAL) # XXX: should be configurable + async def async_update(self): + await self._hass.async_add_executor_job(self._api_update) + + def _api_update(self): + try: + self._session.gettoken() + self._session.getcurrent() + for i in range(-1, - 10, - 1): + self._session.getday(i) + if not self._session.day['NoDataFlag']: + self._session.getsummary(i) + break + _LOGGER.debug(f'No data at index {i}') + _LOGGER.info('Successfully obtained data from ' + + self._session.day['StartDate']) + except Exception as e: + _LOGGER.warn(f'Error updating data: {e}') + + @classmethod + async def update_listener(cls, hass, config_entry): + """ + XXX: find the api object for the entitie, and update its session token + """ + service_agreement_id = config_entry.data.get(CONF_SERVICE_AGREEMENT_ID) + access_token = config_entry.data.get(CONF_ACCESS_TOKEN) + session = await hass.async_add_executor_job( + aurora_init, + access_token + ) + api = cls._instances[service_agreement_id].update_session(session) + + def update_session(self, session): + self._session = session + + def __getattr__(self, attr): + """Forward any attribute access to the session, or handle error """ + if attr == '_throttle': + raise AttributeError() + _LOGGER.debug(f'Accessing data for {attr}') + try: + data = getattr(self._session, attr) + except AttributeError as err: + _LOGGER.debug( + f'Data for {attr} not yet available' + ) + return {} # empty with a get + _LOGGER.debug(f'... returning {data}') + return data + + diff --git a/custom_components/auroraplus/config_flow.py b/custom_components/auroraplus/config_flow.py index 01095a4..0c367bb 100644 --- a/custom_components/auroraplus/config_flow.py +++ b/custom_components/auroraplus/config_flow.py @@ -1,8 +1,122 @@ +import logging +from typing import Any + +import homeassistant.helpers.config_validation as cv from homeassistant import config_entries -from .const import DOMAIN +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, +) +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_MONITORED_CONDITIONS, + CONF_NAME, + CONF_SCAN_INTERVAL, +) +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import ConfigEntryAuthFailed + +from sqlalchemy.exc import SQLAlchemyError + +import voluptuous as vol + +from .api import aurora_init +from .const import ( + CONF_ROUNDING, + CONF_SERVICE_AGREEMENT_ID, + DEFAULT_MONITORED, + DEFAULT_ROUNDING, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + POSSIBLE_MONITORED, +) +_LOGGER = logging.getLogger(__name__) + +# PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +AUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_ACCESS_TOKEN): cv.string, + } +) class AuroraPlusConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """AuroraPlus config flow.""" + VERSION = 0 + MINOR_VERSION = 1 + reauth_entry = None + + async def async_step_user(self, + user_input: dict[str, Any] | None = None + ): + _LOGGER.debug(dir(self)) + return await self._configure(user_input) + + async def _configure(self, + user_input: dict[str, Any] | None = None + ): + """ + Get access_token from the user, and create a new service. + + If self.reauth_entry is set, this entry will be updated instead. + + """ + errors = {} + if user_input is not None: + access_token = user_input.get(CONF_ACCESS_TOKEN) + try: + session = await self.hass.async_add_executor_job( + aurora_init, + access_token + ) + address = session.month['ServiceAgreements'][session.serviceAgreementID]['PremiseName'] + await self.async_set_unique_id(session.serviceAgreementID) + + if self.reauth_entry: + self.hass.config_entries.async_update_entry( + self.reauth_entry, + data={ + CONF_ACCESS_TOKEN: access_token, + CONF_SERVICE_AGREEMENT_ID: session.serviceAgreementID, + }, + ) + await self.hass.config_entries.async_reload( + self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + else: + return self.async_create_entry( + title=address, + data={ + CONF_ACCESS_TOKEN: access_token, + CONF_SERVICE_AGREEMENT_ID: session.serviceAgreementID, + }, + ) + + except ConfigEntryAuthFailed as e: + errors = { + 'base': 'auth', + } + + return self.async_show_form( + step_id="user", + data_schema=AUTH_SCHEMA, + errors=errors, + ) + + async def async_step_reauth(self, user_input=None): + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + async def async_step_reauth_confirm(self, user_input=None): + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({}), + ) + return await self.async_step_user() diff --git a/custom_components/auroraplus/const.py b/custom_components/auroraplus/const.py new file mode 100644 index 0000000..f691927 --- /dev/null +++ b/custom_components/auroraplus/const.py @@ -0,0 +1,26 @@ +import datetime + +DOMAIN = "auroraplus" + +CONF_SERVICE_AGREEMENT_ID = "service_agreement_id" +CONF_ROUNDING = "rounding" + +SENSOR_ESTIMATEDBALANCE = 'Estimated Balance' +SENSOR_DOLLARVALUEUSAGE = 'Dollar Value Usage' +SENSOR_KILOWATTHOURUSAGE = 'Kilowatt Hour Usage' +SENSOR_KILOWATTHOURUSAGETARIFF = 'Kilowatt Hour Usage Tariff' +SENSOR_DOLLARVALUEUSAGETARIFF = 'Dollar Value Usage Tariff' + +SENSORS_MONETARY = [ + SENSOR_ESTIMATEDBALANCE, + SENSOR_DOLLARVALUEUSAGE, +] + + +POSSIBLE_MONITORED = SENSORS_MONETARY + [SENSOR_KILOWATTHOURUSAGE] + +DEFAULT_MONITORED = POSSIBLE_MONITORED + +DEFAULT_ROUNDING = 2 +DEFAULT_SCAN_INTERVAL = datetime.timedelta(hours=1) + diff --git a/custom_components/auroraplus/manifest.json b/custom_components/auroraplus/manifest.json index 48230ea..c32e8c2 100644 --- a/custom_components/auroraplus/manifest.json +++ b/custom_components/auroraplus/manifest.json @@ -5,8 +5,11 @@ "issue_tracker": "https://github.com/LeighCurran/AuroraPlusHA/issues", "dependencies": [], "codeowners": ["@LeighCurran"], - "requirements": ["AuroraPlus==1.1.6"], + "requirements": [ + "auroraplus@git+https://github.com/shtrom/AuroraPlus@oauth-mfa-token", + "homeassistant-historical-sensor==2.0.0rc5" + ], "iot_class": "cloud_polling", - "config_flow": false, + "config_flow": true, "version": "1.1.9" } diff --git a/custom_components/auroraplus/sensor.py b/custom_components/auroraplus/sensor.py index defed30..184d38a 100644 --- a/custom_components/auroraplus/sensor.py +++ b/custom_components/auroraplus/sensor.py @@ -1,92 +1,121 @@ """Support for Aurora+""" -from datetime import timedelta +import datetime import logging -import auroraplus import voluptuous as vol +from homeassistant.exceptions import ( + ConfigEntryNotReady, + IntegrationError, + PlatformNotReady, +) + +import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - STATE_CLASS_TOTAL, - STATE_CLASS_MEASUREMENT, - SensorEntity + SensorEntity, + SensorStateClass, ) +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMetaData, +) +from homeassistant.components.recorder.statistics import StatisticsRow +from homeassistant.components.sensor.const import ( + SensorDeviceClass, +) from homeassistant.const import ( - CONF_USERNAME, - CONF_PASSWORD, - CONF_NAME, + CONF_ACCESS_TOKEN, CONF_MONITORED_CONDITIONS, - CURRENCY_DOLLAR, - ENERGY_KILO_WATT_HOUR, + CONF_NAME, CONF_SCAN_INTERVAL, - DEVICE_CLASS_MONETARY, - DEVICE_CLASS_ENERGY, + CURRENCY_DOLLAR, + UnitOfEnergy, +) +from homeassistant_historical_sensor import ( + HistoricalSensor, + HistoricalState, + PollUpdateMixin, ) -CONF_ROUNDING = "rounding" - -import homeassistant.helpers.config_validation as cv +from .api import AuroraApi, aurora_init _LOGGER = logging.getLogger(__name__) -SENSOR_ESTIMATEDBALANCE = 'Estimated Balance' -SENSOR_DOLLARVALUEUSAGE = 'Dollar Value Usage' -SENSOR_KILOWATTHOURUSAGE = 'Kilowatt Hour Usage' - -POSSIBLE_MONITORED = [SENSOR_ESTIMATEDBALANCE, SENSOR_DOLLARVALUEUSAGE, SENSOR_KILOWATTHOURUSAGE] +from .config_flow import ( + DEFAULT_ROUNDING, +) +from .const import ( + CONF_ROUNDING, + DEFAULT_MONITORED, + DOMAIN, + POSSIBLE_MONITORED, + SENSORS_MONETARY, + SENSOR_DOLLARVALUEUSAGE, + SENSOR_DOLLARVALUEUSAGETARIFF, + SENSOR_ESTIMATEDBALANCE, + SENSOR_KILOWATTHOURUSAGE, + SENSOR_KILOWATTHOURUSAGETARIFF, +) -DEFAULT_MONITORED = POSSIBLE_MONITORED -DEFAULT_NAME = 'Aurora+' -DEFAULT_ROUNDING = 2 +async def async_setup_entry(hass, config_entry, + async_add_entities, + discovery_info=None): + """Set up the Aurora+ platform for sensors.""" + config = hass.data[DOMAIN][config_entry.entry_id] + name = 'AuroraPlus' + rounding = config.get(CONF_ROUNDING, DEFAULT_ROUNDING) -DEFAULT_SCAN_INTERVAL = timedelta(hours=1) + aurora_api = config_entry.runtime_data + await aurora_api.async_update() -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_ROUNDING, default=DEFAULT_ROUNDING): vol.Coerce(int), - vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.time_period, - vol.Optional(CONF_MONITORED_CONDITIONS, default=DEFAULT_MONITORED): - vol.All(cv.ensure_list, [vol.In(POSSIBLE_MONITORED)]) - } -) + tariffs = aurora_api.month['TariffTypes'] + if not tariffs: + raise ConfigEntryNotReady('Empty tariffs in returned data') -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Aurora+ platform for sensors.""" - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - name = config.get(CONF_NAME) - rounding = config.get(CONF_ROUNDING) + sensors_energy = [ + f'{SENSOR_KILOWATTHOURUSAGETARIFF} {t}' + for t in tariffs + ] + sensors_cost = [ + f'{SENSOR_DOLLARVALUEUSAGETARIFF} {t}' + for t in tariffs + ] - try: - AuroraPlus = auroraplus.api(username, password) - _LOGGER.debug("Error: %s", AuroraPlus.Error) - except OSError as err: - _LOGGER.error("Connection to Aurora+ failed: %s", err) - for sensor in config.get(CONF_MONITORED_CONDITIONS): - _LOGGER.debug("Adding sensor: %s", sensor) - add_entities([AuroraSensor(username, password, sensor, name, AuroraPlus, rounding)], True) + async_add_entities([ + AuroraSensor(hass, + sensor, name, + aurora_api, rounding) + for sensor in config.get(CONF_MONITORED_CONDITIONS, DEFAULT_MONITORED) + ] + [ + AuroraHistoricalSensor(hass, + sensor, name, + aurora_api, rounding) + for sensor in sensors_energy + sensors_cost + ], + True) + _LOGGER.info(f'Aurora+ platform ready with tariffs {tariffs}') class AuroraSensor(SensorEntity): """Representation of a Aurora+ sensor.""" - def __init__(self, username, password, sensor, name, auroraplus, rounding): + def __init__(self, hass, sensor, name, aurora_api, rounding): """Initialize the Aurora+ sensor.""" - self._username = username - self._password = password - self._name = name + ' ' + sensor + self._hass = hass + self._name = (name + ' ' + + aurora_api.service_agreement_id + ' ' + + sensor) self._sensor = sensor self._unit_of_measurement = None self._state = None - self._session = auroraplus - self._uniqueid = self._name + self._last_reset = None + self._api = aurora_api + self._uniqueid = self._name.replace(' ', '_').lower() self._rounding = rounding + _LOGGER.debug(f'{self._sensor} created') @property def name(self): @@ -97,22 +126,23 @@ def name(self): def state(self): """Return the state of the sensor.""" return self._state - + + @property + def last_reset(self): + return self._last_reset + @property def state_class(self): """Return the state class of the sensor.""" - if self._sensor == SENSOR_ESTIMATEDBALANCE: - return STATE_CLASS_MEASUREMENT - else: - return STATE_CLASS_TOTAL + return SensorStateClass.TOTAL @property def device_class(self): """Return device class fo the sensor.""" - if self._sensor == SENSOR_KILOWATTHOURUSAGE: - return DEVICE_CLASS_ENERGY + if self._sensor in SENSORS_MONETARY: + return SensorDeviceClass.MONETARY else: - return DEVICE_CLASS_MONETARY + return SensorDeviceClass.ENERGY @property def unique_id(self): @@ -122,46 +152,190 @@ def unique_id(self): @property def unit_of_measurement(self): """Return the unit of measurement.""" - if self._sensor == SENSOR_KILOWATTHOURUSAGE: - return ENERGY_KILO_WATT_HOUR - else: + if self._sensor in SENSORS_MONETARY: return CURRENCY_DOLLAR + else: + return UnitOfEnergy.KILO_WATT_HOUR @property def extra_state_attributes(self): """Return device state attributes.""" - if self._sensor == SENSOR_DOLLARVALUEUSAGE: - return self._session.DollarValueUsage - elif self._sensor == SENSOR_KILOWATTHOURUSAGE: - return self._session.KilowattHourUsage - elif self._sensor == SENSOR_ESTIMATEDBALANCE: + if self._sensor == SENSOR_DOLLARVALUEUSAGE: + return self._api.DollarValueUsage + elif self._sensor == SENSOR_KILOWATTHOURUSAGE: + return self._api.KilowattHourUsage + elif self._sensor == SENSOR_ESTIMATEDBALANCE: attributes = {} - attributes['Amount Owed'] = self._session.AmountOwed - attributes['Average Daily Usage'] = self._session.AverageDailyUsage - attributes['Usage Days Remaining'] = self._session.UsageDaysRemaining - attributes['Actual Balance'] = self._session.ActualBalance - attributes['Unbilled Amount'] = self._session.UnbilledAmount - attributes['Bill Total Amount'] = self._session.BillTotalAmount - attributes['Number Of Unpaid Bills'] = self._session.NumberOfUnpaidBills - attributes['Bill Overdue Amount'] = self._session.BillOverDueAmount + attributes['Amount Owed'] = self._api.AmountOwed + attributes['Average Daily Usage'] = self._api.AverageDailyUsage + attributes['Usage Days Remaining'] = self._api.UsageDaysRemaining + attributes['Actual Balance'] = self._api.ActualBalance + attributes['Unbilled Amount'] = self._api.UnbilledAmount + attributes['Bill Total Amount'] = self._api.BillTotalAmount + attributes['Number Of Unpaid Bills'] = self._api.NumberOfUnpaidBills + attributes['Bill Overdue Amount'] = self._api.BillOverDueAmount return attributes - def update(self): - try: - _LOGGER.debug("Updating sensor: %s", self._sensor) - self._session.getcurrent() - if self._sensor == SENSOR_KILOWATTHOURUSAGE or self._sensor == SENSOR_DOLLARVALUEUSAGE: - self._session.getsummary() - self._data = self._session - except OSError as err: - _LOGGER.error("Updating Aurora+ failed: %s", err) - + async def async_update(self): """Collect updated data from Aurora+ API.""" + await self._api.async_update() + + self._old_state = self._state if self._sensor == SENSOR_ESTIMATEDBALANCE: - self._state = round(float(self._session.EstimatedBalance),self._rounding) - elif self._sensor == SENSOR_DOLLARVALUEUSAGE: - self._state = round(self._session.DollarValueUsage['Total'],self._rounding) - elif self._sensor == SENSOR_KILOWATTHOURUSAGE: - self._state = round(self._session.KilowattHourUsage['Total'],self._rounding) + self._state = round( + float(self._api.EstimatedBalance), self._rounding) + elif self._sensor == SENSOR_DOLLARVALUEUSAGE: + self._state = round( + self._api.DollarValueUsage.get('Total', float('nan')), + self._rounding) + elif self._sensor == SENSOR_KILOWATTHOURUSAGE: + self._state = round( + self._api.KilowattHourUsage.get('Total', float('nan')), + self._rounding) + else: - _LOGGER.error("Unknown sensor type found") \ No newline at end of file + _LOGGER.warn(f'{self._sensor}: Unknown sensor type') + if self._old_state and self._state != self._old_state: + self._last_reset = datetime.datetime.now() + + +class AuroraHistoricalSensor(PollUpdateMixin, HistoricalSensor, SensorEntity): + def __init__(self, hass, sensor, name, aurora_api, rounding): + """Initialize the Aurora+ sensor.""" + self._hass = hass + self._name = (name + ' ' + + aurora_api.service_agreement_id + ' ' + + sensor) + self._sensor = sensor + self._unit_of_measurement = None + self._attr_historical_states = [] + self._api = aurora_api + self._uniqueid = self._name.replace(' ', '_').lower() + self._rounding = rounding + _LOGGER.debug(f'{self._sensor} created (historical)') + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + # @property + # def state_class(self): + # """Return the state class of the sensor.""" + # return SensorStateClass.TOTAL + + @property + def device_class(self): + """Return device class fo the sensor. + This method does some string-parsing and error handling magic, + so others don't have to, to determine the type of sensor. + """ + if self._sensor.startswith(SENSOR_DOLLARVALUEUSAGETARIFF): + return SensorDeviceClass.MONETARY + elif self._sensor.startswith(SENSOR_KILOWATTHOURUSAGETARIFF): + return SensorDeviceClass.ENERGY + else: + raise IntegrationError( + f'{self._sensor} is not handled by {self.__class__}' + ) + + @property + def unique_id(self): + """Return the unique_id of the sensor.""" + return self._uniqueid + + @property + def statistic_id(self) -> str: + return 'sensor.' + self._uniqueid + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + if self.device_class == SensorDeviceClass.MONETARY: + return CURRENCY_DOLLAR + elif self.device_class == SensorDeviceClass.ENERGY: + return UnitOfEnergy.KILO_WATT_HOUR + + @property + def historical_states(self): + """Return the historical state of the sensor.""" + return self._attr_historical_states + + async def async_update_historical(self): + if self.device_class == SensorDeviceClass.MONETARY: + tariff = self._sensor.removeprefix( + SENSOR_DOLLARVALUEUSAGETARIFF + ).strip() + field = 'DollarValueUsage' + elif self._sensor.startswith(SENSOR_KILOWATTHOURUSAGETARIFF): + tariff = self._sensor.removeprefix( + SENSOR_KILOWATTHOURUSAGETARIFF + ).strip() + field = 'KilowattHourUsage' + + await self._api.async_update() + + metered_records = self._api.day.get( + 'MeteredUsageRecords' + ) + if metered_records is None: + _LOGGER.warning( + f"{self._sensor}: no metered records, can't obtain hourly data" + ) + return + + self._attr_historical_states = [ + HistoricalState( + state=abs(float(r[field][tariff])), + dt=datetime.datetime.fromisoformat(r['StartTime']) + ) + for r in metered_records + if r + and r.get(field) + and r.get(field).get(tariff) + ] + + if not self._attr_historical_states: + _LOGGER.debug( + f"{self._sensor}: empty historical states for tariff {tariff}" + ) + + _LOGGER.debug(f'{self._sensor}: historical states: %s', + self._attr_historical_states) + + def get_statistic_metadata(self) -> StatisticMetaData: + meta = super().get_statistic_metadata() + meta["has_sum"] = True + + return meta + + async def async_calculate_statistic_data( + self, + hist_states: list[HistoricalState], + *, + latest: StatisticsRow | None = None, + ) -> list[StatisticData]: + """Calculate statistics over multiple sampling periods. + + This code works for both energy and monetary sensors by fluke: The + Aurora+ API returns hourly energy consumption only, and daily monetary + cost only, both as part of the same data array. The format allows us to + calculate correct statistics by simply ignoring the empty records. + """ + accumulated = latest.get('sum', 0) if latest else 0 + + ret = [] + + for hs in hist_states: + accumulated = accumulated + hs.state + ret.append( + StatisticData( + start=hs.dt, + state=hs.state, + sum=accumulated, + ) + ) + + _LOGGER.debug(f'{self._sensor}: calculated statistics %s', + ret) + return ret diff --git a/custom_components/auroraplus/strings.json b/custom_components/auroraplus/strings.json new file mode 100644 index 0000000..c026462 --- /dev/null +++ b/custom_components/auroraplus/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "reauth_successful": "Successfully reauthenticated to Aurora+!" + }, + "error": {"auth": "An error occured while authenticating"}, + "step": { + "reauth_confirm": { + "description": "The Aurora+ integration needs to re-authenticate your account", + "title": "Re-authentication needed" + }, + "user": { + "data": {"access_token": "Access Token"}, + "description": "Follow https://github.com/shtrom/AuroraPlus/tree/oauth-mfa-token?tab=readme-ov-file#obtain-a-token to obtain an access token.", + "title": "Authentication to Aurora+" + } + } + } +} diff --git a/custom_components/auroraplus/translations/en.json b/custom_components/auroraplus/translations/en.json new file mode 100644 index 0000000..c026462 --- /dev/null +++ b/custom_components/auroraplus/translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "reauth_successful": "Successfully reauthenticated to Aurora+!" + }, + "error": {"auth": "An error occured while authenticating"}, + "step": { + "reauth_confirm": { + "description": "The Aurora+ integration needs to re-authenticate your account", + "title": "Re-authentication needed" + }, + "user": { + "data": {"access_token": "Access Token"}, + "description": "Follow https://github.com/shtrom/AuroraPlus/tree/oauth-mfa-token?tab=readme-ov-file#obtain-a-token to obtain an access token.", + "title": "Authentication to Aurora+" + } + } + } +}