Octopus: support export tariffs (#23742)

This commit is contained in:
duck. 2025-10-03 16:32:08 +01:00 • committed by GitHub
parent b2ca1f4c18
commit f2e45771ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 113 additions and 44 deletions

View file

@ -2,6 +2,7 @@ package tariff
import (
"errors"
"fmt"
"slices"
"strings"
"sync"
@ -16,13 +17,14 @@ import (
)
type Octopus struct {
log *util.Logger
region string
productCode string
apikey string
accountnumber string
paymentMethod string
data *util.Monitor[api.Rates]
log *util.Logger
region string
productCode string
apikey string
accountnumber string
paymentMethod string
tariffDirection octoGql.TariffDirection
data *util.Monitor[api.Rates]
}
var _ api.Tariff = (*Octopus)(nil)
@ -31,14 +33,27 @@ func init() {
registry.Add("octopusenergy", NewOctopusFromConfig)
}
// NewOctopusFromConfig creates the tariff provider from the given config map, and runs it.
func NewOctopusFromConfig(other map[string]interface{}) (api.Tariff, error) {
t, err := buildOctopusFromConfig(other)
if err != nil {
return nil, err
}
return runOrError(t)
}
// buildOctopusFromConfig creates the Tariff provider from the given config map.
// Split out to allow for testing.
func buildOctopusFromConfig(other map[string]interface{}) (*Octopus, error) {
var cc struct {
Region string
Tariff string // DEPRECATED: use ProductCode
ProductCode string
DirectDebit bool
ApiKey string
AccountNumber string
Region string
Tariff string // DEPRECATED: use ProductCode
ProductCode string
DirectDebit bool
ApiKey string
AccountNumber string
TariffDirection octoGql.TariffDirection
}
logger := util.NewLogger("octopus")
@ -47,6 +62,19 @@ func NewOctopusFromConfig(other map[string]interface{}) (api.Tariff, error) {
return nil, err
}
switch cc.TariffDirection {
case "", octoGql.TariffDirectionImport:
// default to Import if unset
if cc.TariffDirection == "" {
cc.TariffDirection = octoGql.TariffDirectionImport
}
case octoGql.TariffDirectionExport:
// OK
default:
// Do not permit invalid TariffDirections.
return nil, fmt.Errorf("invalid tariff direction %q", cc.TariffDirection)
}
// Allow ApiKey to be missing only if Region and Tariff are not.
if cc.ApiKey == "" {
if cc.Region == "" {
@ -60,12 +88,17 @@ func NewOctopusFromConfig(other map[string]interface{}) (api.Tariff, error) {
if cc.ProductCode == "" {
return nil, errors.New("missing product code")
}
if cc.TariffDirection != octoGql.TariffDirectionImport {
// Throw a WARN if it appears the user has set the key when it's not necessary to do so
logger.WARN.Println("tariffDirection ignored when using product code")
}
} else {
// ApiKey validators
if cc.Region != "" || cc.Tariff != "" {
return nil, errors.New("cannot use apikey at same time as product code")
}
if len(cc.ApiKey) != 32 || !strings.HasPrefix(cc.ApiKey, "sk_live_") {
// We permit the specific special apiKey "test" as sk_live_ keys are considered Stripe secrets by Github
if cc.ApiKey != "test" && (len(cc.ApiKey) != 32 || !strings.HasPrefix(cc.ApiKey, "sk_live_")) {
return nil, errors.New("invalid apikey format")
}
}
@ -74,17 +107,19 @@ func NewOctopusFromConfig(other map[string]interface{}) (api.Tariff, error) {
// Not using Direct Debit, filter by non-Direct Debit tariff entries
paymentMethod = octoRest.RatePaymentMethodNotDirectDebit
}
t := &Octopus{
log: logger,
region: cc.Region,
productCode: cc.ProductCode,
apikey: cc.ApiKey,
accountnumber: cc.AccountNumber,
paymentMethod: paymentMethod,
data: util.NewMonitor[api.Rates](2 * time.Hour),
log: logger,
region: cc.Region,
productCode: cc.ProductCode,
apikey: cc.ApiKey,
accountnumber: cc.AccountNumber,
paymentMethod: paymentMethod,
tariffDirection: cc.TariffDirection,
data: util.NewMonitor[api.Rates](2 * time.Hour),
}
return runOrError(t)
return t, nil
}
func (t *Octopus) run(done chan error) {
@ -101,7 +136,7 @@ func (t *Octopus) run(done chan error) {
t.log.ERROR.Println(err)
return
}
tariffCode, err := gqlCli.TariffCode()
tariffCode, err := gqlCli.TariffCode(t.tariffDirection)
if err != nil {
once.Do(func() { done <- err })
t.log.ERROR.Println(err)

View file

@ -3,6 +3,7 @@ package graphql
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"time"
@ -131,8 +132,8 @@ func (c *OctopusGraphQLClient) AccountNumber() (accountNumber string, err error)
return c.accountNumber, nil
}
// TariffCode queries the Tariff Code of the first IMPORT Electricity Agreement active on the account.
func (c *OctopusGraphQLClient) TariffCode() (string, error) {
// TariffCode queries the Tariff Code of the first valid Electricity Agreement active on the account that matches the given TariffDirection.
func (c *OctopusGraphQLClient) TariffCode(direction TariffDirection) (string, error) {
// Update refresh token (if necessary)
if err := c.refreshToken(); err != nil {
return "", err
@ -156,17 +157,11 @@ func (c *OctopusGraphQLClient) TariffCode() (string, error) {
return "", errors.New("no electricity agreements found")
}
// check type
// (theoretically) not needed for our uses
//switch t := q.Account.ElectricityAgreements[0].Tariff.(type) {
//
//}
// Filter out any export tariffs; select the first import tariff.
// Filter out any inappropriate tariffs; select the first tariff that aligns with our configuration.
var tariffCode string
for _, agreement := range q.Account.ElectricityAgreements {
if agreement.Tariff.IsExport() {
c.log.TRACE.Println("GraphQL: filtering export tariff", agreement.Tariff.TariffCode())
if agreement.Tariff.TariffDirection() != direction {
c.log.TRACE.Println("GraphQL: filtering tariff with incorrect import/export type:", agreement.Tariff.TariffCode())
continue
}
tariffCode = agreement.Tariff.TariffCode()
@ -174,7 +169,7 @@ func (c *OctopusGraphQLClient) TariffCode() (string, error) {
}
if tariffCode == "" {
return "", errors.New("no import electricity agreement found")
return "", fmt.Errorf("no electricity agreement for type %s", direction)
}
c.log.TRACE.Println("GraphQL: tariff code found:", tariffCode)

View file

@ -38,9 +38,22 @@ func (d *tariffData) TariffCode() string {
return d.standardTariff.TariffCode
}
// IsExport is a shortcut function for determining whether the given tariff is for export, regardless of tariff type.
func (d *tariffData) IsExport() bool {
return d.standardTariff.IsExport
// TariffDirection defines which direction of energy flow is being denoted by the given tariff.
type TariffDirection string
const (
// TariffDirectionImport is for energy flow INTO the meter FROM the grid (to the property)
TariffDirectionImport TariffDirection = "import"
// TariffDirectionExport is for energy flow OUT OF the meter FROM the property (to the grid)
TariffDirectionExport TariffDirection = "export"
)
func (d *tariffData) TariffDirection() TariffDirection {
if d.standardTariff.IsExport {
return TariffDirectionExport
}
return TariffDirectionImport
}
type tariffType struct {

View file

@ -17,7 +17,7 @@ func TestOctopusConfigParse(t *testing.T) {
"directDebit": "True",
}
_, err := NewOctopusFromConfig(validTariffConfig)
_, err := buildOctopusFromConfig(validTariffConfig)
require.NoError(t, err)
validProductCodeConfig := map[string]interface{}{
@ -26,14 +26,29 @@ func TestOctopusConfigParse(t *testing.T) {
"directDebit": "False",
}
_, err = NewOctopusFromConfig(validProductCodeConfig)
_, err = buildOctopusFromConfig(validProductCodeConfig)
require.NoError(t, err)
invalidApiAndProductCodeConfig := map[string]interface{}{
"region": "H",
"productcode": "GO-22-03-29",
"apikey": "nope",
"region": "H",
"productcode": "GO-22-03-29",
"tariffDirection": "import",
"apikey": "invalid_key",
}
_, err = NewOctopusFromConfig(invalidApiAndProductCodeConfig)
_, err = buildOctopusFromConfig(invalidApiAndProductCodeConfig)
require.Error(t, err)
invalidTariffDirectionConfig := map[string]interface{}{
"tariffDirection": "invalid",
"apikey": "test",
}
_, err = buildOctopusFromConfig(invalidTariffDirectionConfig)
require.Errorf(t, err, "invalid tariff type")
validApiExportConfig := map[string]interface{}{
"tariffDirection": "export",
"apikey": "test",
}
_, err = buildOctopusFromConfig(validApiExportConfig)
require.NoError(t, err)
}

View file

@ -23,7 +23,18 @@ params:
en: "Only required if you have multiple accounts."
de: "Nur erforderlich, wenn mehrere Konten vorhanden sind."
example: "X-XXXXXXXX"
- name: tariffDirection
type: choice
choice: ["import", "export"]
required: false
default: "import"
description:
generic: The tariff flow direction to query from Octopus.
help:
generic: "Set to 'export' when using feedin:"
render: |
type: octopusenergy
apikey: {{ .apiKey }}
accountNumber: {{ .accountNumber }}
tariffDirection: {{ .tariffDirection }}