tariff/localvolts.go
Rama master · réplica de lectura del repositorio original.
package tariff
import (
"errors"
"fmt"
"slices"
"strings"
"sync"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
)
// https://localvolts.com/wp-content/uploads/2026/10/LvAPI_20261002.pdf
const localVoltsURI = "https://api.localvolts.com/v2/customer/interval?NMI=%s&from=current&to=1day"
type localVoltsInterval struct {
Circuit string `json:"circuit"`
IntervalEnd time.Time `json:"intervalEnd"`
IntervalDuration int `json:"intervalDuration,string"` // minutes, sent as string despite the docs
FlexUp float64 `json:"flexUp"` // c/kWh
}
type LocalVolts struct {
*embed
*request.Helper
log *util.Logger
uri string
channel string
data *util.Monitor[api.Rates]
}
var _ api.Tariff = (*LocalVolts)(nil)
func init() {
registry.Add("localvolts", NewLocalVoltsFromConfig)
}
func NewLocalVoltsFromConfig(other map[string]any) (api.Tariff, error) {
var cc struct {
embed `mapstructure:",squash"`
ApiKey string
Partner string
Nmi string
Channel string
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
if cc.ApiKey == "" || cc.Partner == "" {
return nil, api.ErrMissingCredentials
}
if cc.Nmi == "" {
return nil, errors.New("missing nmi")
}
if cc.Channel == "" {
return nil, errors.New("missing channel")
}
if err := cc.init(); err != nil {
return nil, err
}
log := util.NewLogger("localvolts").Redact(cc.ApiKey)
t := &LocalVolts{
embed: &cc.embed,
log: log,
Helper: request.NewHelper(log),
uri: fmt.Sprintf(localVoltsURI, cc.Nmi),
channel: cc.Channel,
data: util.NewMonitor[api.Rates](2 * time.Hour),
}
t.Client.Transport = &transport.Decorator{
Base: t.Client.Transport,
Decorator: transport.DecorateHeaders(map[string]string{
"Authorization": "apikey " + cc.ApiKey,
"partner": cc.Partner,
}),
}
return runOrError(t)
}
func (t *LocalVolts) run(done chan error) {
var once sync.Once
for tick := time.Tick(time.Minute); ; <-tick {
var res []localVoltsInterval
if err := backoff.Retry(func() error {
return backoffPermanentError(t.GetJSON(t.uri, &res))
}, bo()); err != nil {
if reportError(&once, done, err) {
return
}
t.log.ERROR.Println(err)
continue
}
data := t.rates(res)
if len(data) == 0 {
err := fmt.Errorf("no data for channel %s", t.channel)
if reportError(&once, done, err) {
return
}
t.log.ERROR.Println(err)
continue
}
mergeRates(t.data, data)
once.Do(func() { close(done) })
}
}
// rates averages the 5-minute NEM intervals of the configured circuit into 15-minute slots.
// flexUp is the marginal price per kWh: positive means cost on Import and revenue on Export,
// which matches evcc's sign convention for grid and feed-in tariffs.
func (t *LocalVolts) rates(res []localVoltsInterval) api.Rates {
type bucket struct {
sum float64
n int
}
buckets := make(map[time.Time]*bucket)
for _, r := range res {
if !strings.EqualFold(r.Circuit, t.channel) {
continue
}
start := r.IntervalEnd.Add(-time.Duration(r.IntervalDuration) * time.Minute).Local()
slot := start.Truncate(SlotDuration)
b, ok := buckets[slot]
if !ok {
b = &bucket{}
buckets[slot] = b
}
b.sum += t.totalPrice(r.FlexUp/1e2, start)
b.n++
}
data := make(api.Rates, 0, len(buckets))
for slot, b := range buckets {
data = append(data, api.Rate{
Start: slot,
End: slot.Add(SlotDuration),
Value: b.sum / float64(b.n),
})
}
data.Sort()
return data
}
// Rates implements the api.Tariff interface
func (t *LocalVolts) Rates() (api.Rates, error) {
var res api.Rates
err := t.data.GetFunc(func(val api.Rates) {
res = slices.Clone(val)
})
return res, err
}
func (t *LocalVolts) Unit() string {
return "AUD"
}
// Type implements the api.Tariff interface
func (t *LocalVolts) Type() api.TariffType {
return api.TariffTypePriceForecast
}