From f2e45771ed5099e852211b6dc8688141be219419 Mon Sep 17 00:00:00 2001 From: "duck." Date: Fri, 3 Oct 2025 16:32:08 +0100 Subject: [PATCH] Octopus: support export tariffs (#23742) --- tariff/octopus.go | 81 ++++++++++++++------ tariff/octopus/graphql/api.go | 19 ++--- tariff/octopus/graphql/types.go | 19 ++++- tariff/octopus_test.go | 27 +++++-- templates/definition/tariff/octopus-api.yaml | 11 +++ 5 files changed, 113 insertions(+), 44 deletions(-) diff --git a/tariff/octopus.go b/tariff/octopus.go index f73450005..3f717865e 100644 --- a/tariff/octopus.go +++ b/tariff/octopus.go @@ -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) diff --git a/tariff/octopus/graphql/api.go b/tariff/octopus/graphql/api.go index e940f52b7..e7b332b84 100644 --- a/tariff/octopus/graphql/api.go +++ b/tariff/octopus/graphql/api.go @@ -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) diff --git a/tariff/octopus/graphql/types.go b/tariff/octopus/graphql/types.go index 6b4b79b1b..42ae0ea62 100644 --- a/tariff/octopus/graphql/types.go +++ b/tariff/octopus/graphql/types.go @@ -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 { diff --git a/tariff/octopus_test.go b/tariff/octopus_test.go index 19bbd26a5..ef8fa8638 100644 --- a/tariff/octopus_test.go +++ b/tariff/octopus_test.go @@ -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) } diff --git a/templates/definition/tariff/octopus-api.yaml b/templates/definition/tariff/octopus-api.yaml index 6ce7aba85..cc0f769b4 100644 --- a/templates/definition/tariff/octopus-api.yaml +++ b/templates/definition/tariff/octopus-api.yaml @@ -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 }}