Github Mirror / telegraf-plugins: 119767a2




Merge pull request #3 from bentasker/octopus-tariffs

Merge pull request #3 from bentasker/octopus-tariffs

Add Octopus Energy Plugin (utilities/telegraf-plugins#14)

Commit 119767a2.

Authored 2023-07-06T19:44:00.000+01:00 by Ben Tasker in project Github Mirror / telegraf-plugins

Committed by 2023-07-06T19:44:00.000+01:00 by GitHub

+361 lines -0 lines

Commit Signature

Changes

diff --git a/octopus-energy/.README.md.kate-swp b/octopus-energy/.README.md.kate-swp
--- a/octopus-energy/.README.md.kate-swp
+++ b/octopus-energy/.README.md.kate-swp
# Binary files /dev/null and b/octopus-energy/.README.md.kate-swp differ
#
diff --git a/octopus-energy/README.md b/octopus-energy/README.md
--- a/octopus-energy/README.md
+++ b/octopus-energy/README.md
# @@ -0,0 +1,95 @@
# +### Octopus Energy Telegraf Plugin
# +
# +This is an exec based plugin for Telegraf designed to collect Electricity usage and tariff details from the [Octopus Energy API](https://developer.octopus.energy/docs/api/)
# +
# +See [utilities/telegraf-plugins#14](https://projects.bentasker.co.uk/gils_projects/issue/utilities/telegraf-plugins/14.html) for more information on the original intent of the plugin.
# +
# +
# +Note: Octopus fetch consumption from the meter once daily, so although this plugin can collect consumption it cannot provide a realtime view (you need something like the [Glow IHD](https://www.bentasker.co.uk/posts/blog/house-stuff/connecting-my-smart-meter-to-influxdb-using-telegraf-and-a-glow-display.html) or the [Octopus Home Mini](https://octopus.energy/blog/octopus-home-mini/) for that).
# +
# +
# +----
# +
# +### Dependencies
# +
# +* Python >= 3
# +* Python Requests Module
# +
# +----
# +
# +### Pre-Requisites
# +
# +You will need
# +
# +* Your API key
# +* Your account number
# +
# +
# +----
# +
# +### Telegraf Config
# +
# +The API details can be passed via the `environment` configuration option
# +
# +```ini
# +[[inputs.exec]]
# + commands = [
# + "/usr/local/src/telegraf_plugins/octopus-energy.py",
# + ]
# + timeout = "60s"
# + interval = "1h"
# + name_suffix = ""
# + data_format = "influx"
# + # Update the value of these
# + environment = [
# + "OCTOPUS_KEY=",
# + "OCTOPUS_ACCOUNT="
# + ]
# +```
# +
# +The plugin doesn't need to be run too often - Octopus fetch consumption information from your meter once daily, so consumption stats are not generally available until the day after.
# +
# +Tariff details (if you're on a tariff like Agile Octopus) are update more frequently though, so it's worth triggering the plugin once an hour to capture the latest pricing (as well as future pricing).
# +
# +----
# +
# +### Measurements and Fields
# +
# +- `octopus_consumption`
# + - Fields:
# + - consumption (`kWh` consumed in period)
# + - Tags:
# + - meter_serial: serial number of the meter
# + - mpan
# + - tariff_code: Octopus's tariff code (allowing Joining to pricing info)
# +- `octopus_meter`
# + - Fields:
# + - start_date: when service started for this meter
# + - Tags:
# + - account: Octopus account number
# + - mpan
# + - property: Octopus property ID
# +- `octopus_pricing`
# + - Fields:
# + - cost_exc_vat: unit price excluding vat
# + - cost_inc_vat: unit price including vat
# + - valid_from: time price came into effect
# + - valid_to: when price ends (or `None` if no current expiry)
# + - Tags:
# + - charge_type: Standing-charge or usage-charge
# + - payment_method: whether it's the Direct Debit or Non Direct Debit price
# + - tariff_code: The octopus tariff code
# +
# +Notes:
# +
# +- The `octopus_meter` measurement exists purely to make information on the meter available, it's most useful values are the tagset rather than the fields
# +- The timestamp used in `octopus_consumption` is the *end* of the indicated period, so if Octopus's API reports 1kWh used between 00:00 and 00:30, the timestamp on the point will be for 00:30
# +- For ease of joining, the plugin calculates `octopus_pricing` points every 30 minutes between `valid_from` and `valid_to`
# +- Export tariffs don't currently have any specific handling (that will be added later)
# +- Gas isn't currently handled
# +
# +----
# +
# +### License
# +
# +Copyright 2023, B Tasker. Released under [BSD 3 Clause](https://www.bentasker.co.uk/pages/licenses/bsd-3-clause.html).
#
diff --git a/octopus-energy/octopus-energy.py b/octopus-energy/octopus-energy.py
--- a/octopus-energy/octopus-energy.py
+++ b/octopus-energy/octopus-energy.py
# @@ -0,0 +1,266 @@
# +#!/usr/bin/env python3
# +#
# +# Telegraf exec plugin to poll the Octopus API and retrieve
# +# pricing and consumption information
# +#
# +# Copyright (c) 2023, B Tasker
# +# Released under BSD 3-Clause License
# +#
# +
# +'''
# +Copyright (c) 2023, B Tasker
# +
# +All rights reserved.
# +
# +Redistribution and use in source and binary forms, with or without modification, are
# +permitted provided that the following conditions are met:
# +
# +1. Redistributions of source code must retain the above copyright notice, this list of
# +conditions and the following disclaimer.
# +
# +2. Redistributions in binary form must reproduce the above copyright notice, this list of
# +conditions and the following disclaimer in the documentation and/or other materials
# +provided with the distribution.
# +
# +3. Neither the name of the copyright holder nor the names of its contributors may be used
# +to endorse or promote products derived from this software without specific prior written
# +permission.
# +
# +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
# +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
# +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# +COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
# +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
# +TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
# +'''
# +
# +from datetime import datetime as dt
# +from datetime import timedelta as tdel
# +import os
# +import requests
# +import base64
# +
# +def getConsumption(meter, session):
# + ''' Call the API and fetch consumption information
# + '''
# + # Calculate the `from` date to apply (1 day ago)
# + tday = dt.now()
# + yday = tday - tdel(days=2)
# +
# + from_str = yday.strftime("%Y-%m-%d %H:%M:%SZ")
# +
# + result = session.get(f"https://api.octopus.energy/v1/electricity-meter-points/{meter['mpan']}/meters/{meter['serial']}/consumption?period_from={from_str}")
# +
# + return result.json()['results']
# +
# +def getPricing(meter, session):
# + ''' Calculate the product code and fetch pricing info
# + '''
# + # We've got a tariff code (for example E-1R-VAR-22-11-01-A)
# + # the product code is embedded into it - VAR-22-11-01
# + # the A at the end of the example is the region code
# + #
# + # Split the tariff code up
# + tariff_split = meter['tariff-code'].split("-")
# + product_code = '-'.join(tariff_split[2:-1])
# + meter['region'] = tariff_split[-1]
# +
# + # Calculate the `from` date to apply (1 day ago)
# + tday = dt.now()
# + yday = tday - tdel(days=1)
# +
# + from_str = yday.strftime("%Y-%m-%d %H:%M:%SZ")
# +
# + # We can now use this to retrieve pricing
# + #
# + # TODO: we should check if the meter records the tariff type as STANDARD
# + # if not, we should be looking at day-unit/night-unit
# + result = session.get(f"https://api.octopus.energy/v1/products/{product_code}/electricity-tariffs/{meter['tariff-code']}/standard-unit-rates?period_from={from_str}")
# +
# + for pricepoint in result.json()['results']:
# + pricepoint['type'] = "usage-charge"
# + meter['pricing'].append(pricepoint)
# +
# + # We also need to grab the daily standing charges
# + result = session.get(f"https://api.octopus.energy/v1/products/{product_code}/electricity-tariffs/{meter['tariff-code']}/standing-charges?period_from={from_str}")
# +
# + for pricepoint in result.json()['results']:
# + pricepoint['type'] = "standing-charge"
# + meter['pricing'].append(pricepoint)
# +
# + return meter
# +
# +def generateLP(addresses):
# + ''' Take the built dict and generate multiple lines of LP
# + '''
# +
# + lp_buffer = []
# + for address in addresses:
# + base_tagset = {
# + "address_id" : address['id'],
# + "account_number" : address['account_number']
# + }
# +
# + # Iterate through any electricity meters
# + for meter in address['meters']:
# + tagset = base_tagset | {
# + "mpan" : meter['mpan'],
# + "meter_serial" : meter['serial'],
# + "region_code" : meter['region']
# + }
# +
# + # Iterate through prices
# + for price in meter['pricing']:
# + lp_buffer = lp_buffer + priceToLP(price, meter['tariff-code'])
# +
# + # and through consumption
# + for consumed in meter['consumption']:
# + lp_buffer.append(consumedToLP(consumed, meter))
# +
# + return lp_buffer
# +
# +def consumedToLP(consumed, meter):
# + ''' Build LP indicating consumption
# + '''
# +
# + tags = [
# + "octopus_consumption", # the measurement name
# + f"mpan={meter['mpan']}",
# + f"meter_serial={meter['serial']}",
# + f"tariff_code={meter['tariff-code']}"
# + ]
# +
# + fields = [
# + f"consumption={consumed['consumption']}",
# + ]
# +
# + # Calculate the timestamp
# + # timezones can fluctuate
# + try:
# + ts = int(dt.strptime(consumed['interval_end'], '%Y-%m-%dT%H:%M:%SZ').strftime('%s'))
# + except:
# + ts = int(dt.strptime(consumed['interval_end'], '%Y-%m-%dT%H:%M:%S+01:00').strftime('%s'))
# +
# + return " ".join([','.join(tags), ','.join(fields), str(ts * 1000000000)])
# +
# +
# +
# +def priceToLP(price, tariff_code):
# + ''' Take a pricing dict and generate LP
# + '''
# + {'value_exc_vat': 29.2574, 'value_inc_vat': 30.72027, 'valid_from': '2023-06-30T23:00:00Z', 'valid_to': None, 'payment_method': 'DIRECT_DEBIT'}
# +
# +
# + tags = [
# + "octopus_pricing", # the measurement name
# + f"payment_method={price['payment_method']}",
# + f"tariff_code={tariff_code}",
# + f"charge_type={price['type']}"
# + ]
# +
# + fields = [
# + f"cost_exc_vat={price['value_exc_vat']}",
# + f"cost_inc_vat={price['value_inc_vat']}",
# + f"valid_from=\"{price['valid_from']}\"",
# + f"valid_to=\"{price['valid_to']}\""
# + ]
# +
# + lp = " ".join([','.join(tags), ','.join(fields)])
# +
# + # We now need to generate a line for every 30 minutes between valid_from and valid_to
# + # if valid_to is "None" we should use now()
# + #
# + # Convert to epoch and then we can just iterate through in 30 min chunks
# + if not price['valid_to']:
# + valid_to = int(dt.now().strftime('%s'))
# + else:
# + valid_to = int(dt.strptime(price['valid_to'], '%Y-%m-%dT%H:%M:%SZ').strftime('%s'))
# +
# + valid_from = int(dt.strptime(price['valid_from'], '%Y-%m-%dT%H:%M:%SZ').strftime('%s'))
# +
# + # Iterate through
# + lp_buffer = []
# + while valid_from < valid_to:
# + lp_buffer.append(f"{lp} {valid_from * 1000000000}")
# + valid_from = valid_from + 1800
# +
# + return lp_buffer
# +
# +
# +def main(api_key, octo_account):
# + ''' Main entry point
# +
# + Fetch property details and identify meters
# + '''
# + if not api_key:
# + return False
# +
# + # Set up a session allowing KA
# + session = requests.session()
# + auth_val = base64.b64encode(f"{api_key}:".encode('utf-8')).decode()
# + headers = {
# + "User-Agent" : "telegraf plugin (https://github.com/bentasker/telegraf-plugins/tree/master/octopus-tariffs)",
# + "Authorization" : f"Basic {auth_val}"
# +
# + }
# + # Make these the default
# + session.headers = headers
# +
# + # Get Account info
# + #
# + # This gives us MPAN and tariff info
# + result = session.get(f"https://api.octopus.energy/v1/accounts/{octo_account}")
# + account = result.json()
# +
# + addresses = []
# + lp_buffer = []
# + # Iterate through addresses
# + for prop in account['properties']:
# + prop_info = {
# + "id" : prop['id'],
# + "meters" : [],
# + "account_number" : octo_account,
# + "start_date" : prop['moved_in_at']
# + }
# + # Iterate through meter points
# + for meter_point in prop['electricity_meter_points']:
# + meter_info = {
# + "mpan" : meter_point['mpan'],
# + "pricing" : []
# + }
# + for meter in meter_point['meters']:
# + meter_info['serial'] = meter['serial_number']
# +
# + for agreement in meter_point['agreements']:
# + meter_info = meter_info | {
# +
# + "tariff-code" : agreement['tariff_code'],
# + "from" : agreement['valid_from'],
# + "to" : agreement['valid_to']
# + }
# +
# + # Get tariff info
# + meter_info = getPricing(meter_info, session)
# +
# + # Get consumption
# + meter_info['consumption'] = getConsumption(meter_info, session)
# +
# + # Create some LP for the meter itself
# + lp = f'octopus_meter,mpan={meter_info["mpan"]},property={prop_info["id"]},account={prop_info["account_number"]} start_date="{prop_info["start_date"]}" {int(dt.now().strftime("%s")) * 1000000000}'
# + lp_buffer.append(lp)
# +
# + prop_info['meters'].append(meter_info)
# + addresses.append(prop_info)
# +
# + # Turn it into LP
# + lp_buffer = lp_buffer + generateLP(addresses)
# + [print(x) for x in lp_buffer]
# +
# +
# +if __name__ == "__main__":
# + api_key = os.getenv("OCTOPUS_KEY", False)
# + mpan = os.getenv("OCTOPUS_ACCOUNT", False)
# + main(api_key, mpan)
#