Skip to content

Commit

Permalink
Merge pull request #8 from BottlecapDave/feature/gas-conversion
Browse files Browse the repository at this point in the history
Feature/gas conversion
  • Loading branch information
BottlecapDave authored Sep 10, 2021
2 parents 5f1414e + 5777120 commit e8cc475
Show file tree
Hide file tree
Showing 8 changed files with 255 additions and 128 deletions.
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ Ideally, you'd be able to use the consumption sensors as part of your [energy da

If you go through the [setup](https://my.home-assistant.io/redirect/config_flow_start/?domain=octopus_energy) process after you've configured your account, you can set up target rate sensors. These sensors calculate the lowest continuous or intermittent points and turn on when these rates are active. These sensors can then be used in automations to turn on/off devices the save you money (and in theory be on when there's the most renewable energy).

### Gas Meters

When you sign into your account, if you have gas meters, we'll setup some sensors for you. However, the way these sensors report data isn't consistent between versions of the meters, and Octopus Energy doesn't expose what type of meter you have. Therefore, you have to toggle the checkbox when setting up your initial account within HA. If you've already setup your account, you can update this via the `Configure` option within the integrations configuration. This is a global setting, and therefore will apply to **all** gas meters.

## Known Issues/Limitations

- Latest consumption is at the mercy of how often Octopus Energy updates their records. This seems to be a day behind based on local testing.
- Only the first property associated with an account is exposed.
- Only the first property associated with an account is exposed.
- Gas meter SMETS1/SMETS2 setting has to be set globally and manually as Octopus Energy doesn't provide this information.
120 changes: 72 additions & 48 deletions custom_components/octopus_energy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
import re
from datetime import timedelta
from homeassistant.util.dt import utcnow
import asyncio

from .const import (
DOMAIN,

CONFIG_MAIN_API_KEY,
CONFIG_MAIN_ACCOUNT_ID,

CONFIG_TARGET_NAME,

DATA_CLIENT,
Expand All @@ -27,55 +29,8 @@

_LOGGER = logging.getLogger(__name__)

def setup_dependencies(hass, config):
"""Setup the coordinator and api client which will be shared by various entities"""
client = OctopusEnergyApiClient(config[CONFIG_MAIN_API_KEY])
hass.data[DOMAIN][DATA_CLIENT] = client

async def async_update_data():
"""Fetch data from API endpoint."""
# Only get data every half hour or if we don't have any data
if (DATA_RATES not in hass.data[DOMAIN] or (utcnow().minute % 30) == 0 or len(hass.data[DOMAIN][DATA_RATES]) == 0):

# FIX: Ideally we'd only get the tariffs once at the start, but it's not working
account_info = await client.async_get_account(config[CONFIG_MAIN_ACCOUNT_ID])

current_agreement = None
if len(account_info["electricity_meter_points"]) > 0:
# We're only interested in the tariff of the first electricity point
for point in account_info["electricity_meter_points"]:
current_agreement = get_active_agreement(point["agreements"])
if current_agreement != None:
break

if current_agreement == None:
raise

tariff_code = current_agreement["tariff_code"]
matches = re.search(REGEX_PRODUCT_NAME, tariff_code)
if matches == None:
raise

product_code = matches[1]

_LOGGER.info('Updating rates...')
hass.data[DOMAIN][DATA_RATES] = await client.async_get_rates(product_code, tariff_code)

return hass.data[DOMAIN][DATA_RATES]

hass.data[DOMAIN][DATA_COORDINATOR] = DataUpdateCoordinator(
hass,
_LOGGER,
name="rates",
update_method=async_update_data,
# Because of how we're using the data, we'll update every minute, but we will only actually retrieve
# data every 30 minutes
update_interval=timedelta(minutes=1),
)

async def async_setup_entry(hass, entry):
"""This is called from the config flow."""

hass.data.setdefault(DOMAIN, {})

if CONFIG_MAIN_API_KEY in entry.data:
Expand All @@ -90,5 +45,74 @@ async def async_setup_entry(hass, entry):
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, "binary_sensor")
)

entry.async_on_unload(entry.add_update_listener(options_update_listener))

return True

def setup_dependencies(hass, config):
"""Setup the coordinator and api client which will be shared by various entities"""

if DATA_CLIENT not in hass.data[DOMAIN]:
client = OctopusEnergyApiClient(config[CONFIG_MAIN_API_KEY])
hass.data[DOMAIN][DATA_CLIENT] = client

async def async_update_data():
"""Fetch data from API endpoint."""
# Only get data every half hour or if we don't have any data
if (DATA_RATES not in hass.data[DOMAIN] or (utcnow().minute % 30) == 0 or len(hass.data[DOMAIN][DATA_RATES]) == 0):

# FIX: Ideally we'd only get the tariffs once at the start, but it's not working
account_info = await client.async_get_account(config[CONFIG_MAIN_ACCOUNT_ID])

current_agreement = None
if len(account_info["electricity_meter_points"]) > 0:
# We're only interested in the tariff of the first electricity point
for point in account_info["electricity_meter_points"]:
current_agreement = get_active_agreement(point["agreements"])
if current_agreement != None:
break

if current_agreement == None:
raise

tariff_code = current_agreement["tariff_code"]
matches = re.search(REGEX_PRODUCT_NAME, tariff_code)
if matches == None:
raise

product_code = matches[1]

_LOGGER.info('Updating rates...')
hass.data[DOMAIN][DATA_RATES] = await client.async_get_rates(product_code, tariff_code)

return hass.data[DOMAIN][DATA_RATES]

hass.data[DOMAIN][DATA_COORDINATOR] = DataUpdateCoordinator(
hass,
_LOGGER,
name="rates",
update_method=async_update_data,
# Because of how we're using the data, we'll update every minute, but we will only actually retrieve
# data every 30 minutes
update_interval=timedelta(minutes=1),
)

async def options_update_listener(hass, entry):
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

async def async_unload_entry(hass, entry):
"""Unload a config entry."""
if CONFIG_MAIN_API_KEY in entry.data:
target_domain = "sensor"
elif CONFIG_TARGET_NAME in entry.data:
target_domain = "binary_sensor"

unload_ok = all(
await asyncio.gather(
*[hass.config_entries.async_forward_entry_unload(entry, target_domain)]
)
)

return True
return unload_ok
3 changes: 1 addition & 2 deletions custom_components/octopus_energy/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@
CONFIG_TARGET_START_TIME,
CONFIG_TARGET_END_TIME,

DATA_COORDINATOR,
DATA_CLIENT
DATA_COORDINATOR
)

_LOGGER = logging.getLogger(__name__)
Expand Down
105 changes: 62 additions & 43 deletions custom_components/octopus_energy/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from homeassistant.config_entries import ConfigFlow
import re
import voluptuous as vol
import logging

from homeassistant.config_entries import (ConfigFlow, OptionsFlow)
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv

from .const import (
DOMAIN,

Expand All @@ -11,34 +14,21 @@

CONFIG_TARGET_NAME,
CONFIG_TARGET_HOURS,
CONFIG_TARGET_TYPE,
CONFIG_TARGET_START_TIME,
CONFIG_TARGET_END_TIME,

CONFIG_SMETS1,

DATA_SCHEMA_ACCOUNT,
DATA_SCHEMA_TARGET,

REGEX_TIME,
REGEX_ENTITY_NAME,
REGEX_HOURS
)

import homeassistant.helpers.config_validation as cv
from .api_client import OctopusEnergyApiClient

ACCOUNT_DATA_SCHEMA = vol.Schema({
vol.Required(CONFIG_MAIN_API_KEY): str,
vol.Required(CONFIG_MAIN_ACCOUNT_ID): str,
})

TARGET_DATA_SCHEMA = vol.Schema({
vol.Required(CONFIG_TARGET_NAME): str,
vol.Required(CONFIG_TARGET_HOURS): str,
vol.Required(CONFIG_TARGET_TYPE, default="Continuous"): vol.In({
"Continuous": "Continuous",
"Intermittent": "Intermittent"
}),
vol.Optional(CONFIG_TARGET_START_TIME): str,
vol.Optional(CONFIG_TARGET_END_TIME): str,
})

_LOGGER = logging.getLogger(__name__)

class OctopusEnergyConfigFlow(ConfigFlow, domain=DOMAIN):
Expand All @@ -55,29 +45,20 @@ async def async_setup_initial_account(self, user_input):
if (account_info == None):
errors[CONFIG_MAIN_ACCOUNT_ID] = "account_not_found"
return self.async_show_form(
step_id="user", data_schema=ACCOUNT_DATA_SCHEMA, errors=errors
step_id="user", data_schema=DATA_SCHEMA_ACCOUNT, errors=errors
)

config = {
CONFIG_MAIN_API_KEY: user_input[CONFIG_MAIN_API_KEY],
CONFIG_MAIN_ACCOUNT_ID: user_input[CONFIG_MAIN_ACCOUNT_ID]
}

# Setup our basic sensors
return self.async_create_entry(
title="Octopus Energy",
data=config
data=user_input
)

async def async_step_target_rate(self, user_input):
"""Setup a target based on the provided user input"""
errors = {}
config = {
CONFIG_TARGET_NAME: user_input[CONFIG_TARGET_NAME],
CONFIG_TARGET_TYPE: user_input[CONFIG_TARGET_TYPE]
}

matches = re.search(REGEX_ENTITY_NAME, config[CONFIG_TARGET_NAME])
matches = re.search(REGEX_ENTITY_NAME, user_input[CONFIG_TARGET_NAME])
if matches == None:
errors[CONFIG_TARGET_NAME] = "invalid_target_name"

Expand All @@ -86,32 +67,32 @@ async def async_step_target_rate(self, user_input):
if matches == None:
errors[CONFIG_TARGET_HOURS] = "invalid_target_hours"
else:
config[CONFIG_TARGET_HOURS] = float(user_input[CONFIG_TARGET_HOURS])
if config[CONFIG_TARGET_HOURS] % 0.5 != 0:
user_input[CONFIG_TARGET_HOURS] = float(user_input[CONFIG_TARGET_HOURS])
if user_input[CONFIG_TARGET_HOURS] % 0.5 != 0:
errors[CONFIG_TARGET_HOURS] = "invalid_target_hours"

if CONFIG_TARGET_START_TIME in user_input:
config[CONFIG_TARGET_START_TIME] = user_input[CONFIG_TARGET_START_TIME]
matches = re.search(REGEX_TIME, config[CONFIG_TARGET_START_TIME])
user_input[CONFIG_TARGET_START_TIME] = user_input[CONFIG_TARGET_START_TIME]
matches = re.search(REGEX_TIME, user_input[CONFIG_TARGET_START_TIME])
if matches == None:
errors[CONFIG_TARGET_START_TIME] = "invalid_target_time"

if CONFIG_TARGET_END_TIME in user_input:
config[CONFIG_TARGET_END_TIME] = user_input[CONFIG_TARGET_END_TIME]
matches = re.search(REGEX_TIME, config[CONFIG_TARGET_START_TIME])
user_input[CONFIG_TARGET_END_TIME] = user_input[CONFIG_TARGET_END_TIME]
matches = re.search(REGEX_TIME, user_input[CONFIG_TARGET_START_TIME])
if matches == None:
errors[CONFIG_TARGET_END_TIME] = "invalid_target_time"

if len(errors) < 1:
# Setup our targets sensor
return self.async_create_entry(
title=f"{config[CONFIG_TARGET_NAME]} (target)",
data=config
title=f"{user_input[CONFIG_TARGET_NAME]} (target)",
data=user_input
)

# Reshow our form with raised logins
return self.async_show_form(
step_id="target_rate", data_schema=TARGET_DATA_SCHEMA, errors=errors
step_id="target_rate", data_schema=DATA_SCHEMA_TARGET, errors=errors
)

async def async_step_user(self, user_input):
Expand All @@ -134,9 +115,47 @@ async def async_step_user(self, user_input):

if is_account_setup:
return self.async_show_form(
step_id="target_rate", data_schema=TARGET_DATA_SCHEMA
step_id="target_rate", data_schema=DATA_SCHEMA_TARGET
)

return self.async_show_form(
step_id="user", data_schema=ACCOUNT_DATA_SCHEMA
)
step_id="user", data_schema=DATA_SCHEMA_ACCOUNT
)

@staticmethod
@callback
def async_get_options_flow(entry):
return OptionsFlowHandler(entry)

class OptionsFlowHandler(OptionsFlow):
"""Handles options flow for the component."""

def __init__(self, entry) -> None:
self._entry = entry

async def async_step_init(self, user_input):
"""Manage the options for the custom component."""

if CONFIG_MAIN_API_KEY in self._entry.data:
config = dict(self._entry.data)
if self._entry.options is not None:
config.update(self._entry.options)

return self.async_show_form(
step_id="user", data_schema=vol.Schema({
vol.Optional(CONFIG_SMETS1, default=config[CONFIG_SMETS1]): bool,
})
)

return self.async_abort(reason="not_supported")

async def async_step_user(self, user_input):
"""Manage the options for the custom component."""
errors = {}

if user_input is not None:
config = dict(self._entry.data)
config.update(user_input)
return self.async_create_entry(title="", data=config)

return self.async_abort(reason="not_supported")
22 changes: 21 additions & 1 deletion custom_components/octopus_energy/const.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import voluptuous as vol

DOMAIN = "octopus_energy"

CONFIG_MAIN_API_KEY = "Api key"
CONFIG_MAIN_ACCOUNT_ID = "Account Id"
CONFIG_SMETS1 = "SMETS1"

CONFIG_TARGET_NAME = "Name"
CONFIG_TARGET_HOURS = "Hours"
Expand All @@ -17,4 +20,21 @@
REGEX_HOURS = "^[0-9]+(\.[0-9]+)*$"
REGEX_TIME = "^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$"
REGEX_ENTITY_NAME = "^[a-z0-9_]+$"
REGEX_PRODUCT_NAME = "^[A-Z]-[0-9A-Z]+-([A-Z0-9-]+)-[A-Z]$"
REGEX_PRODUCT_NAME = "^[A-Z]-[0-9A-Z]+-([A-Z0-9-]+)-[A-Z]$"

DATA_SCHEMA_ACCOUNT = vol.Schema({
vol.Required(CONFIG_MAIN_API_KEY): str,
vol.Required(CONFIG_MAIN_ACCOUNT_ID): str,
vol.Optional(CONFIG_SMETS1): bool,
})

DATA_SCHEMA_TARGET = vol.Schema({
vol.Required(CONFIG_TARGET_NAME): str,
vol.Required(CONFIG_TARGET_HOURS): str,
vol.Required(CONFIG_TARGET_TYPE, default="Continuous"): vol.In({
"Continuous": "Continuous",
"Intermittent": "Intermittent"
}),
vol.Optional(CONFIG_TARGET_START_TIME): str,
vol.Optional(CONFIG_TARGET_END_TIME): str,
})
Loading

0 comments on commit e8cc475

Please sign in to comment.