diff --git a/tariff/octopus.go b/tariff/octopus.go index 35d1c8bd9b..d72e92bc6b 100644 --- a/tariff/octopus.go +++ b/tariff/octopus.go @@ -3,20 +3,24 @@ package tariff import ( "errors" "slices" + "strings" "sync" "time" "github.com/cenkalti/backoff/v4" "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/tariff/octopus" + octoGql "github.com/evcc-io/evcc/tariff/octopus/graphql" + octoRest "github.com/evcc-io/evcc/tariff/octopus/rest" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" ) type Octopus struct { log *util.Logger - uri string region string + // Tariff is actually the Product Code + tariff string + apikey string data *util.Monitor[api.Rates] } @@ -29,24 +33,38 @@ func init() { func NewOctopusFromConfig(other map[string]interface{}) (api.Tariff, error) { var cc struct { Region string + // Tariff is actually the Product Code Tariff string + ApiKey string } if err := util.DecodeOther(other, &cc); err != nil { return nil, err } - if cc.Region == "" { - return nil, errors.New("missing region") - } - if cc.Tariff == "" { - return nil, errors.New("missing tariff code") + // Allow ApiKey to be missing only if Region and Tariff are not. + if cc.ApiKey == "" { + if cc.Region == "" { + return nil, errors.New("missing region") + } + if cc.Tariff == "" { + return nil, errors.New("missing product / tariff code") + } + } else { + // ApiKey validators + if cc.Region != "" || cc.Tariff != "" { + return nil, errors.New("cannot use apikey at same time as product / tariff code") + } + if len(cc.ApiKey) != 32 || !strings.HasPrefix(cc.ApiKey, "sk_live_") { + return nil, errors.New("invalid apikey format") + } } t := &Octopus{ log: util.NewLogger("octopus"), - uri: octopus.ConstructRatesAPI(cc.Tariff, cc.Region), - region: cc.Tariff, + region: cc.Region, + tariff: cc.Tariff, + apikey: cc.ApiKey, data: util.NewMonitor[api.Rates](2 * time.Hour), } @@ -62,11 +80,35 @@ func (t *Octopus) run(done chan error) { client := request.NewHelper(t.log) bo := newBackoff() + var restQueryUri string + + // If ApiKey is available, use GraphQL to get appropriate tariff code before entering execution loop. + if t.apikey != "" { + gqlCli, err := octoGql.NewClient(t.log, t.apikey) + if err != nil { + once.Do(func() { done <- err }) + t.log.ERROR.Println(err) + return + } + tariffCode, err := gqlCli.TariffCode() + if err != nil { + once.Do(func() { done <- err }) + t.log.ERROR.Println(err) + return + } + restQueryUri = octoRest.ConstructRatesAPIFromTariffCode(tariffCode) + } else { + // Construct Rest Query URI using tariff and region codes. + restQueryUri = octoRest.ConstructRatesAPIFromProductAndRegionCode(t.tariff, t.region) + } + + // When we eventually get around to writing Intelligent support, + // we'll want to tick every 15 minutes if GraphQL is available to poll for Intelligent slots. for ; true; <-time.Tick(time.Hour) { - var res octopus.UnitRates + var res octoRest.UnitRates if err := backoff.Retry(func() error { - return client.GetJSON(t.uri, &res) + return client.GetJSON(restQueryUri, &res) }, bo); err != nil { once.Do(func() { done <- err }) diff --git a/tariff/octopus/api.go b/tariff/octopus/api.go deleted file mode 100644 index 1f96453a95..0000000000 --- a/tariff/octopus/api.go +++ /dev/null @@ -1,35 +0,0 @@ -package octopus - -import ( - "fmt" - "strings" - "time" -) - -// ProductURI defines the location of the tariff information page. Substitute %s with tariff name. -const ProductURI = "https://api.octopus.energy/v1/products/%s/" - -// RatesURI defines the location of the full tariff rates page, including speculation. -// Substitute first %s with tariff name, second with region code. -const RatesURI = ProductURI + "electricity-tariffs/E-1R-%s-%s/standard-unit-rates/" - -// ConstructRatesAPI returns a validly formatted, fully qualified URI to the unit rate information. -func ConstructRatesAPI(tariff string, region string) string { - t := strings.ToUpper(tariff) - r := strings.ToUpper(region) - return fmt.Sprintf(RatesURI, t, t, r) -} - -type UnitRates struct { - Count uint64 `json:"count"` - Next string `json:"next"` - Previous string `json:"previous"` - Results []Rate `json:"results"` -} - -type Rate struct { - ValidityStart time.Time `json:"valid_from"` - ValidityEnd time.Time `json:"valid_to"` - PriceInclusiveTax float64 `json:"value_inc_vat"` - PriceExclusiveTax float64 `json:"value_exc_vat"` -} diff --git a/tariff/octopus/graphql/api.go b/tariff/octopus/graphql/api.go new file mode 100644 index 0000000000..0de39e3e12 --- /dev/null +++ b/tariff/octopus/graphql/api.go @@ -0,0 +1,155 @@ +package graphql + +import ( + "context" + "errors" + "net/http" + "sync" + "time" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/hasura/go-graphql-client" +) + +// BaseURI is Octopus Energy's core API root. +const BaseURI = "https://api.octopus.energy" + +// URI is the GraphQL query endpoint for Octopus Energy. +const URI = BaseURI + "/v1/graphql/" + +// OctopusGraphQLClient provides an interface for communicating with Octopus Energy's Kraken platform. +type OctopusGraphQLClient struct { + *graphql.Client + log *util.Logger + + // apikey is the Octopus Energy API key (provided by user) + apikey string + + // token is the GraphQL token used for communication with kraken (we get this ourselves with the apikey) + token *string + // tokenExpiration tracks the expiry of the acquired token. A new Token should be obtained if this time is passed. + tokenExpiration time.Time + // tokenMtx should be held when requesting a new token. + tokenMtx sync.Mutex + + // accountNumber is the Octopus Energy account number associated with the given API key (queried ourselves via GraphQL) + accountNumber string +} + +// NewClient returns a new, unauthenticated instance of OctopusGraphQLClient. +func NewClient(log *util.Logger, apikey string) (*OctopusGraphQLClient, error) { + cli := request.NewClient(log) + + gq := &OctopusGraphQLClient{ + Client: graphql.NewClient(URI, cli), + log: log, + apikey: apikey, + } + + err := gq.refreshToken() + if err != nil { + return nil, err + } + // Future requests must have the appropriate Authorization header set. + reqMod := graphql.RequestModifier( + func(r *http.Request) { + r.Header.Add("Authorization", *gq.token) + }) + gq.Client = gq.Client.WithRequestModifier(reqMod) + + return gq, err +} + +// refreshToken updates the GraphQL token from the set apikey. +// Basic caching is provided - it will not update the token if it hasn't expired yet. +func (c *OctopusGraphQLClient) refreshToken() error { + now := time.Now() + if !c.tokenExpiration.IsZero() && c.tokenExpiration.After(now) { + c.log.TRACE.Print("using cached octopus token") + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + // take a lock against the token mutex for the refresh + c.tokenMtx.Lock() + defer c.tokenMtx.Unlock() + + var q krakenTokenAuthentication + err := c.Client.Mutate(ctx, &q, map[string]interface{}{"apiKey": c.apikey}) + if err != nil { + return err + } + c.log.TRACE.Println("got GQL token from octopus") + c.token = &q.ObtainKrakenToken.Token + // Refresh in 55 minutes (the token lasts an hour, but just to be safe...) + c.tokenExpiration = time.Now().Add(time.Minute * 55) + return nil +} + +// AccountNumber queries the Account Number assigned to the associated API key. +// Caching is provided. +func (c *OctopusGraphQLClient) AccountNumber() (string, error) { + // Check cache + if c.accountNumber != "" { + return c.accountNumber, nil + } + + // Update refresh token (if necessary) + if err := c.refreshToken(); err != nil { + return "", err + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + var q krakenAccountLookup + err := c.Client.Query(ctx, &q, nil) + if err != nil { + return "", err + } + + if len(q.Viewer.Accounts) == 0 { + return "", errors.New("no account associated with given octopus api key") + } + if len(q.Viewer.Accounts) > 1 { + c.log.WARN.Print("more than one octopus account on this api key - picking the first one. please file an issue!") + } + c.accountNumber = q.Viewer.Accounts[0].Number + return c.accountNumber, nil +} + +// TariffCode queries the Tariff Code of the first Electricity Agreement active on the account. +func (c *OctopusGraphQLClient) TariffCode() (string, error) { + // Update refresh token (if necessary) + if err := c.refreshToken(); err != nil { + return "", err + } + + // Get Account Number + acc, err := c.AccountNumber() + if err != nil { + return "", nil + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + var q krakenAccountElectricityAgreements + err = c.Client.Query(ctx, &q, map[string]interface{}{"accountNumber": acc}) + if err != nil { + return "", err + } + + if len(q.Account.ElectricityAgreements) == 0 { + return "", errors.New("no electricity agreements found") + } + + // check type + //switch t := q.Account.ElectricityAgreements[0].Tariff.(type) { + // + //} + return q.Account.ElectricityAgreements[0].Tariff.TariffCode(), nil +} diff --git a/tariff/octopus/graphql/types.go b/tariff/octopus/graphql/types.go new file mode 100644 index 0000000000..0a9b2f129c --- /dev/null +++ b/tariff/octopus/graphql/types.go @@ -0,0 +1,79 @@ +package graphql + +// krakenTokenAuthentication is a representation of a GraphQL query for obtaining a Kraken API token. +type krakenTokenAuthentication struct { + ObtainKrakenToken struct { + Token string + } `graphql:"obtainKrakenToken(input: {APIKey: $apiKey})"` +} + +// krakenAccountLookup is a representation of a GraphQL query for obtaining the Account Number associated with the +// credentials used to authorize the request. +type krakenAccountLookup struct { + Viewer struct { + Accounts []struct { + Number string + } + } +} + +type tariffData struct { + // yukky but the best way I can think of to handle this + // access via any relevant tariff data entry (i.e. standardTariff) + standardTariff `graphql:"... on StandardTariff"` + dayNightTariff `graphql:"... on DayNightTariff"` + threeRateTariff `graphql:"... on ThreeRateTariff"` + halfHourlyTariff `graphql:"... on HalfHourlyTariff"` + prepayTariff `graphql:"... on PrepayTariff"` +} + +// TariffCode is a shortcut function to obtaining the Tariff Code of the given tariff, regardless of tariff type. +// Developer Note: GraphQL query returns the same element keys regardless of type, +// so it should always be decoded as standardTariff at least. +// We are unlikely to use the other Tariff types for data access (?). +func (d *tariffData) TariffCode() string { + return d.standardTariff.TariffCode +} + +type tariffType struct { + Id string + DisplayName string + FullName string + ProductCode string + StandingCharge float32 + PreVatStandingCharge float32 +} + +type tariffTypeWithTariffCode struct { + tariffType + TariffCode string +} + +type standardTariff struct { + tariffTypeWithTariffCode +} +type dayNightTariff struct { + tariffTypeWithTariffCode +} +type threeRateTariff struct { + tariffTypeWithTariffCode +} +type halfHourlyTariff struct { + tariffTypeWithTariffCode +} +type prepayTariff struct { + tariffTypeWithTariffCode +} + +type krakenAccountElectricityAgreements struct { + Account struct { + ElectricityAgreements []struct { + Id int + Tariff tariffData + MeterPoint struct { + // Mpan is the serial number of the meter that this ElectricityAgreement is bound to. + Mpan string + } + } `graphql:"electricityAgreements(active: true)"` + } `graphql:"account(accountNumber: $accountNumber)"` +} diff --git a/tariff/octopus/rest/api.go b/tariff/octopus/rest/api.go new file mode 100644 index 0000000000..a1308edd3a --- /dev/null +++ b/tariff/octopus/rest/api.go @@ -0,0 +1,48 @@ +package rest + +import ( + "fmt" + "strings" + "time" +) + +// ProductURI defines the location of the tariff information page. Substitute %s with tariff name. +const ProductURI = "https://api.octopus.energy/v1/products/%s/" + +// RatesURI defines the location of the full tariff rates page, including speculation. +// Substitute first %s with product code, second with tariff code. +const RatesURI = ProductURI + "electricity-tariffs/%s/standard-unit-rates/" + +// ConstructRatesAPIFromProductAndRegionCode returns a validly formatted, fully qualified URI to the unit rate information +// derived from the given product code and region. +func ConstructRatesAPIFromProductAndRegionCode(product string, region string) string { + tCode := strings.ToUpper(fmt.Sprintf("E-1R-%s-%s", product, region)) + return fmt.Sprintf(RatesURI, product, tCode) +} + +// ConstructRatesAPIFromTariffCode returns a validly formatted, fully qualified URI to the unit rate information +// derived from the given Tariff Code. +func ConstructRatesAPIFromTariffCode(tariff string) string { + // Hacky bullshit, saves handling both the product and tariff codes in GQL mode. + // Hopefully Octopus don't change how this works otherwise we might have to do this properly :( + if len(tariff) < 7 { + // OOB check + return "" + } + pCode := tariff[5 : len(tariff)-2] + return fmt.Sprintf(RatesURI, pCode, tariff) +} + +type UnitRates struct { + Count uint64 `json:"count"` + Next string `json:"next"` + Previous string `json:"previous"` + Results []Rate `json:"results"` +} + +type Rate struct { + ValidityStart time.Time `json:"valid_from"` + ValidityEnd time.Time `json:"valid_to"` + PriceInclusiveTax float64 `json:"value_inc_vat"` + PriceExclusiveTax float64 `json:"value_exc_vat"` +}