Skip to content

Commit

Permalink
Merge pull request #31 from BottlecapDave/develop
Browse files Browse the repository at this point in the history
Develop into main
  • Loading branch information
BottlecapDave authored Dec 22, 2021
2 parents 03356d8 + b6f04f8 commit 4598a4f
Show file tree
Hide file tree
Showing 6 changed files with 420 additions and 237 deletions.
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ To install, place the contents of `custom_components` into the `<config director

## How to setup

Setup is done entirely via the [UI](https://my.home-assistant.io/redirect/config_flow_start/?domain=octopus_energy).
Setup is done entirely via the [integration UI](https://my.home-assistant.io/redirect/config_flow_start/?domain=octopus_energy).

### Your account

Expand All @@ -23,16 +23,18 @@ You'll get the following sensors if you have an electricity meter with an active

You'll get the following sensors for each electricity meter with an active agreement:

* `sensor.octopus_energy_electricity_{{METER_SERIAL_NUMBER}}_latest_consumption` - The latest consumption reported by the meter. It looks like Octopus is about a day behind with their data, therefore this is often zero and will probably be removed in the future.
* `sensor.octopus_energy_electricity_{{METER_SERIAL_NUMBER}}_previous_accumulative_consumption` - The total consumption reported by the meter for the previous day.
* `sensor.octopus_energy_electricity_{{METER_SERIAL_NUMBER}}_previous_accumulative_cost` - The total cost for the previous day, including the standing charge.

You'll get the following sensors for each gas meter with an active agreement:

* `sensor.octopus_energy_gas_{{METER_SERIAL_NUMBER}}_latest_consumption` - The latest consumption reported by the meter. It looks like Octopus is about a day behind with their data, therefore this is often zero and will probably be removed in the future.
* `sensor.octopus_energy_gas_{{METER_SERIAL_NUMBER}}_previous_accumulative_consumption` - The total consumption reported by the meter for the previous day.
* `sensor.octopus_energy_gas_{{METER_SERIAL_NUMBER}}_previous_accumulative_cost` - The total cost for the previous day, including the standing charge.

While you can add these sensors to [energy dashboard](https://www.home-assistant.io/blog/2021/08/04/home-energy-management/), because Octopus doesn't provide live consumption data, it will be off by a day.

Please note, that it's not possible to include current consumption sensors. This is due to Octopus Energy only providing data up to the previous day.

### Target Rates

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 prices and turn on when these periods are active. These sensors can then be used in automations to turn on/off devices that save you (and the planet) energy and money.
Expand All @@ -45,6 +47,6 @@ When you sign into your account, if you have gas meters, we'll setup some sensor

## 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.
- Octopus Energy only provide data up to the previous day, so it's not possible to expose current consumption. If you would like this to change, then you'll need to email Octopus Energy.
- Only the first property associated with an account that hasn't been moved out of is exposed.
- Gas meter SMETS1/SMETS2 setting has to be set globally and manually as Octopus Energy doesn't provide this information.
21 changes: 10 additions & 11 deletions custom_components/octopus_energy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging
from datetime import timedelta
from homeassistant.util.dt import utcnow
from homeassistant.util.dt import (utcnow, as_utc, parse_datetime)
import asyncio

from .const import (
Expand All @@ -13,7 +13,8 @@

DATA_CLIENT,
DATA_COORDINATOR,
DATA_RATES
DATA_RATES,
DATA_TARIFF_CODE
)

from .api_client import OctopusEnergyApiClient
Expand All @@ -23,7 +24,6 @@
)

from .utils import (
get_tariff_parts,
async_get_active_tariff_code
)

Expand Down Expand Up @@ -79,15 +79,14 @@ async def async_update_data():
if (DATA_RATES not in hass.data[DOMAIN] or (utcnow().minute % 30) == 0 or len(hass.data[DOMAIN][DATA_RATES]) == 0):

tariff_code = await async_get_current_agreement_tariff_code(client, config)
hass.data[DOMAIN][DATA_TARIFF_CODE] = tariff_code
_LOGGER.info(f'tariff_code: {tariff_code}')

tariff_parts = get_tariff_parts(tariff_code)

_LOGGER.info('Updating rates...')
if (tariff_parts["rate"].startswith("1")):
hass.data[DOMAIN][DATA_RATES] = await client.async_get_standard_rates_for_next_two_days(tariff_parts["product_code"], tariff_code)
else:
hass.data[DOMAIN][DATA_RATES] = await client.async_get_day_night_rates_for_next_two_days(tariff_parts["product_code"], tariff_code)

utc_now = utcnow()
period_from = as_utc(parse_datetime(utc_now.strftime("%Y-%m-%dT00:00:00Z")))
period_to = as_utc(parse_datetime((utc_now + timedelta(days=2)).strftime("%Y-%m-%dT00:00:00Z")))

hass.data[DOMAIN][DATA_RATES] = await client.async_get_rates(tariff_code, period_from, period_to)

return hass.data[DOMAIN][DATA_RATES]

Expand Down
229 changes: 153 additions & 76 deletions custom_components/octopus_energy/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
from datetime import (timedelta)
from homeassistant.util.dt import (utcnow, as_utc, now, as_local, parse_datetime)

from .utils import (
get_tariff_parts
)

_LOGGER = logging.getLogger(__name__)

class OctopusEnergyApiClient:
Expand Down Expand Up @@ -33,56 +37,11 @@ async def async_get_account(self, account_id):

return None

def __process_rates(self, data, period_from, period_to, tariff_code):
"""Process the collection of rates to ensure they're in 30 minute periods"""
starting_period_from = period_from
results = []
if ("results" in data):
# We need to normalise our data into 30 minute increments so that all of our rates across all tariffs are the same and it's
# easier to calculate our target rate sensors
for item in data["results"]:
value_exc_vat = float(item["value_exc_vat"])
value_inc_vat = float(item["value_inc_vat"])

if "valid_from" in item and item["valid_from"] != None:
valid_from = as_utc(parse_datetime(item["valid_from"]))

# If we're on a fixed rate, then our current time could be in the past so we should go from
# our target period from date otherwise we could be adjusting times quite far in the past
if (valid_from < starting_period_from):
valid_from = starting_period_from
else:
valid_from = starting_period_from

# Some rates don't have end dates, so we should treat this as our period to target
if "valid_to" in item and item["valid_to"] != None:
target_date = as_utc(parse_datetime(item["valid_to"]))
else:
target_date = period_to

while valid_from < target_date:
valid_to = valid_from + timedelta(minutes=30)
results.append({
"value_exc_vat": value_exc_vat,
"value_inc_vat": value_inc_vat,
"valid_from": valid_from,
"valid_to": valid_to,
"tariff_code": tariff_code
})

valid_from = valid_to
starting_period_from = valid_to

return results

async def async_get_standard_rates_for_next_two_days(self, product_code, tariff_code):
async def async_get_standard_rates(self, product_code, tariff_code, period_from, period_to):
"""Get the current standard rates"""
results = []
async with aiohttp.ClientSession() as client:
auth = aiohttp.BasicAuth(self._api_key, '')
utc_now = utcnow()
period_from = as_utc(parse_datetime(utc_now.strftime("%Y-%m-%dT00:00:00Z")))
period_to = as_utc(parse_datetime((utc_now + timedelta(days=2)).strftime("%Y-%m-%dT00:00:00Z")))
url = f'{self._base_url}/v1/products/{product_code}/electricity-tariffs/{tariff_code}/standard-unit-rates?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}'
async with client.get(url, auth=auth) as response:
try:
Expand All @@ -95,32 +54,11 @@ async def async_get_standard_rates_for_next_two_days(self, product_code, tariff_

return results

def __get_valid_from(self, rate):
return rate["valid_from"]

def __is_between_local_times(self, rate, target_from_time, target_to_time):
"""Determines if a current rate is between two times"""
local_now = now()

rate_local_valid_from = as_local(rate["valid_from"])
rate_local_valid_to = as_local(rate["valid_to"])

# We need to convert our times into local time to account for BST to ensure that our rate is valid between the target times.
from_date_time = as_local(parse_datetime(rate_local_valid_from.strftime(f"%Y-%m-%dT{target_from_time}{local_now.strftime('%z')}")))
to_date_time = as_local(parse_datetime(rate_local_valid_from.strftime(f"%Y-%m-%dT{target_to_time}{local_now.strftime('%z')}")))

_LOGGER.error('is_valid: %s; from_date_time: %s; to_date_time: %s; rate_local_valid_from: %s; rate_local_valid_to: %s', rate_local_valid_from >= from_date_time and rate_local_valid_from < to_date_time, from_date_time, to_date_time, rate_local_valid_from, rate_local_valid_to)

return rate_local_valid_from >= from_date_time and rate_local_valid_from < to_date_time

async def async_get_day_night_rates_for_next_two_days(self, product_code, tariff_code):
async def async_get_day_night_rates(self, product_code, tariff_code, period_from, period_to):
"""Get the current day and night rates"""
results = []
async with aiohttp.ClientSession() as client:
auth = aiohttp.BasicAuth(self._api_key, '')
utc_now = utcnow()
period_from = as_utc(parse_datetime(utc_now.strftime("%Y-%m-%dT00:00:00Z")))
period_to = as_utc(parse_datetime((utc_now + timedelta(days=2)).strftime("%Y-%m-%dT00:00:00Z")))
url = f'{self._base_url}/v1/products/{product_code}/electricity-tariffs/{tariff_code}/day-unit-rates?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}'
async with client.get(url, auth=auth) as response:
try:
Expand Down Expand Up @@ -159,15 +97,19 @@ async def async_get_day_night_rates_for_next_two_days(self, product_code, tariff

return results

def __process_consumption(self, item):
return {
"consumption": float(item["consumption"]),
"interval_start": as_utc(parse_datetime(item["interval_start"])),
"interval_end": as_utc(parse_datetime(item["interval_end"]))
}
async def async_get_rates(self, tariff_code, period_from, period_to):
"""Get the current rates"""

tariff_parts = get_tariff_parts(tariff_code)
product_code = tariff_parts["product_code"]

if (tariff_parts["rate"].startswith("1")):
return await self.async_get_standard_rates(product_code, tariff_code, period_from, period_to)
else:
return await self.async_get_day_night_rates(product_code, tariff_code, period_from, period_to)

async def async_electricity_consumption(self, mpan, serial_number, period_from, period_to):
"""Get the current electricity rates"""
"""Get the current electricity consumption"""
async with aiohttp.ClientSession() as client:
auth = aiohttp.BasicAuth(self._api_key, '')
url = f'{self._base_url}/v1/electricity-meter-points/{mpan}/meters/{serial_number}/consumption?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}'
Expand All @@ -189,6 +131,26 @@ async def async_electricity_consumption(self, mpan, serial_number, period_from,

return None

async def async_gas_rates(self, tariff_code, period_from, period_to):
"""Get the gas rates"""
tariff_parts = get_tariff_parts(tariff_code)
product_code = tariff_parts["product_code"]

results = []
async with aiohttp.ClientSession() as client:
auth = aiohttp.BasicAuth(self._api_key, '')
url = f'{self._base_url}/v1/products/{product_code}/gas-tariffs/{tariff_code}/standard-unit-rates?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}'
async with client.get(url, auth=auth) as response:
try:
# Disable content type check as sometimes it can report text/html
data = await response.json(content_type=None)
results = self.__process_rates(data, period_from, period_to, tariff_code)
except:
_LOGGER.error(f'Failed to extract standard gas rates: {url}')
raise

return results

async def async_gas_consumption(self, mprn, serial_number, period_from, period_to):
"""Get the current gas rates"""
async with aiohttp.ClientSession() as client:
Expand Down Expand Up @@ -223,4 +185,119 @@ async def async_get_products(self, is_variable):
if ("results" in data):
return data["results"]

return []
return []

async def async_get_electricity_standing_charges(self, tariff_code, period_from, period_to):
"""Get the electricity standing charges"""
tariff_parts = get_tariff_parts(tariff_code)
product_code = tariff_parts["product_code"]

result = None
async with aiohttp.ClientSession() as client:
auth = aiohttp.BasicAuth(self._api_key, '')
url = f'{self._base_url}/v1/products/{product_code}/electricity-tariffs/{tariff_code}/standing-charges?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}'
async with client.get(url, auth=auth) as response:
try:
# Disable content type check as sometimes it can report text/html
data = await response.json(content_type=None)
if ("results" in data and len(data["results"]) > 0):
result = {
"value_exc_vat": float(data["results"][0]["value_exc_vat"]),
"value_inc_vat": float(data["results"][0]["value_inc_vat"])
}
except:
_LOGGER.error(f'Failed to extract electricity standing charges: {url}')
raise

return result

async def async_get_gas_standing_charges(self, tariff_code, period_from, period_to):
"""Get the gas standing charges"""
tariff_parts = get_tariff_parts(tariff_code)
product_code = tariff_parts["product_code"]

result = None
async with aiohttp.ClientSession() as client:
auth = aiohttp.BasicAuth(self._api_key, '')
url = f'{self._base_url}/v1/products/{product_code}/gas-tariffs/{tariff_code}/standing-charges?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}'
async with client.get(url, auth=auth) as response:
try:
# Disable content type check as sometimes it can report text/html
data = await response.json(content_type=None)
if ("results" in data and len(data["results"]) > 0):
result = {
"value_exc_vat": float(data["results"][0]["value_exc_vat"]),
"value_inc_vat": float(data["results"][0]["value_inc_vat"])
}
except:
_LOGGER.error(f'Failed to extract gas standing charges: {url}')
raise

return result

def __get_valid_from(self, rate):
return rate["valid_from"]

def __is_between_local_times(self, rate, target_from_time, target_to_time):
"""Determines if a current rate is between two times"""
local_now = now()

rate_local_valid_from = as_local(rate["valid_from"])
rate_local_valid_to = as_local(rate["valid_to"])

# We need to convert our times into local time to account for BST to ensure that our rate is valid between the target times.
from_date_time = as_local(parse_datetime(rate_local_valid_from.strftime(f"%Y-%m-%dT{target_from_time}{local_now.strftime('%z')}")))
to_date_time = as_local(parse_datetime(rate_local_valid_from.strftime(f"%Y-%m-%dT{target_to_time}{local_now.strftime('%z')}")))

_LOGGER.error('is_valid: %s; from_date_time: %s; to_date_time: %s; rate_local_valid_from: %s; rate_local_valid_to: %s', rate_local_valid_from >= from_date_time and rate_local_valid_from < to_date_time, from_date_time, to_date_time, rate_local_valid_from, rate_local_valid_to)

return rate_local_valid_from >= from_date_time and rate_local_valid_from < to_date_time

def __process_consumption(self, item):
return {
"consumption": float(item["consumption"]),
"interval_start": as_utc(parse_datetime(item["interval_start"])),
"interval_end": as_utc(parse_datetime(item["interval_end"]))
}

def __process_rates(self, data, period_from, period_to, tariff_code):
"""Process the collection of rates to ensure they're in 30 minute periods"""
starting_period_from = period_from
results = []
if ("results" in data):
# We need to normalise our data into 30 minute increments so that all of our rates across all tariffs are the same and it's
# easier to calculate our target rate sensors
for item in data["results"]:
value_exc_vat = float(item["value_exc_vat"])
value_inc_vat = float(item["value_inc_vat"])

if "valid_from" in item and item["valid_from"] != None:
valid_from = as_utc(parse_datetime(item["valid_from"]))

# If we're on a fixed rate, then our current time could be in the past so we should go from
# our target period from date otherwise we could be adjusting times quite far in the past
if (valid_from < starting_period_from):
valid_from = starting_period_from
else:
valid_from = starting_period_from

# Some rates don't have end dates, so we should treat this as our period to target
if "valid_to" in item and item["valid_to"] != None:
target_date = as_utc(parse_datetime(item["valid_to"]))
else:
target_date = period_to

while valid_from < target_date:
valid_to = valid_from + timedelta(minutes=30)
results.append({
"value_exc_vat": value_exc_vat,
"value_inc_vat": value_inc_vat,
"valid_from": valid_from,
"valid_to": valid_to,
"tariff_code": tariff_code
})

valid_from = valid_to
starting_period_from = valid_to

return results
1 change: 1 addition & 0 deletions custom_components/octopus_energy/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
DATA_COORDINATOR = "COORDINATOR"
DATA_CLIENT = "CLIENT"
DATA_RATES = "RATES"
DATA_TARIFF_CODE = "TARIFF_CODE"

REGEX_HOURS = "^[0-9]+(\.[0-9]+)*$"
REGEX_TIME = "^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$"
Expand Down
Loading

0 comments on commit 4598a4f

Please sign in to comment.