Skip to content

Mfa token config flow #11

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 24 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1953936
autopep linting
shtrom May 30, 2023
c892456
Switch kWh usage to STATE_CLASS_TOTAL_INCREASING to avoid reset artef…
shtrom May 30, 2023
abf409d
Add per-tariff summary
shtrom May 30, 2023
3a65b81
Use STATE_CLASS_TOTAL with last_reset for everything
shtrom May 31, 2023
713a1ad
Make update asynchronous
shtrom Aug 3, 2023
4a6a71f
Split out AuroraPlus API coordinator
shtrom Aug 3, 2023
2c8e710
Sanitise entity_id
shtrom Aug 3, 2023
069ee6f
Add HistoricalSensor for daily per-tariff statistics #6
shtrom Aug 3, 2023
71c624a
Fix Throttle behaviour
shtrom Aug 9, 2023
0512eff
Cleanup logging
shtrom Aug 9, 2023
52f80a8
Be a bit more defensive on empty data
shtrom Aug 9, 2023
7bbcf57
Update to auroraplus 1.5.0
shtrom Aug 11, 2023
8772b31
Only create entities for available tariffs
shtrom Aug 12, 2023
db56792
Look backwards in time for valid data when updating
shtrom Aug 13, 2023
d912032
Get Tariffs from monthly summary
shtrom Aug 13, 2023
694677a
Add per-tariff daily cost sensors
shtrom Aug 14, 2023
715507e
Try to improve startup reliability and performance
shtrom Aug 14, 2023
6093a04
Saner log verbosity
shtrom Aug 14, 2023
ac7200b
Don't round values in stats
shtrom Aug 14, 2023
4318f93
[REVERTME] Point to AuroraPLus fork
shtrom Aug 13, 2023
0d29630
Use absolute values on hourly measurements
shtrom Aug 15, 2023
7e277e5
Update AuroraPlus branch for MFA
shtrom Nov 28, 2023
dcb9690
Bump homeassistant-historical-sensor to 2.0.0rc5
shtrom Jan 10, 2024
109603d
Add config_flow
shtrom Jun 24, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 43 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: [email protected]
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.
51 changes: 50 additions & 1 deletion custom_components/auroraplus/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,50 @@
"""The auroraplus sensor integration."""
"""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
96 changes: 96 additions & 0 deletions custom_components/auroraplus/api.py
Original file line number Diff line number Diff line change
@@ -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


116 changes: 115 additions & 1 deletion custom_components/auroraplus/config_flow.py
Original file line number Diff line number Diff line change
@@ -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()
26 changes: 26 additions & 0 deletions custom_components/auroraplus/const.py
Original file line number Diff line number Diff line change
@@ -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)

7 changes: 5 additions & 2 deletions custom_components/auroraplus/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Loading