Ctrl K
ComunidadGuías…

tariff/homeassistant.go

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package tariff

import (
	"context"
	"errors"
	"fmt"
	"maps"
	"slices"
	"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/homeassistant"
	"github.com/jinzhu/now"
)

// HomeAssistant provides the solar forecast of the Home Assistant Energy dashboard
type HomeAssistant struct {
	log  *util.Logger
	conn *homeassistant.Connection
	data *util.Monitor[api.Rates]
}

var _ api.Tariff = (*HomeAssistant)(nil)

func init() {
	registry.Add("homeassistant", NewHomeAssistantFromConfig)
}

func NewHomeAssistantFromConfig(other map[string]any) (api.Tariff, error) {
	cc := struct {
		homeassistant.Config `mapstructure:",squash"`
		Interval             time.Duration
	}{
		Interval: time.Hour,
	}

	if err := util.DecodeOther(other, &cc); err != nil {
		return nil, err
	}

	log := util.NewLogger("homeassistant")

	conn, err := cc.NewConnection(log)
	if err != nil {
		return nil, err
	}

	t := &HomeAssistant{
		log:  log,
		conn: conn,
		data: util.NewMonitor[api.Rates](2 * cc.Interval),
	}

	done := make(chan error)
	go t.run(cc.Interval, done)

	if err := <-done; err != nil {
		return nil, err
	}

	return t, nil
}

func (t *HomeAssistant) run(interval time.Duration, done chan error) {
	var once sync.Once

	for ; true; <-time.Tick(interval) {
		var data api.Rates

		if err := backoff.Retry(func() error {
			res, err := t.conn.SolarForecasts(context.Background())
			if err != nil {
				return err
			}
			if data, err = solarForecastRates(res); err != nil {
				return backoff.Permanent(err)
			}
			if len(data) == 0 {
				return backoff.Permanent(errors.New("no solar forecast configured in the energy dashboard"))
			}
			// cover full days, the optimizer horizon ends with the shortest series
			data = zeroFill(data, beginningOfDay(), now.With(data[len(data)-1].Start).BeginningOfDay().AddDate(0, 0, 1))
			return nil
		}, bo()); err != nil {
			if reportError(&once, done, err) {
				return
			}
			t.log.ERROR.Println(err)
			continue
		}

		mergeRatesAfter(t.data, data, beginningOfDay())
		once.Do(func() { close(done) })
	}
}

// solarForecastRates converts the energy per slot of all forecasts to average power in W.
// The slot length of a forecast is its smallest timestamp distance, defaulting to one hour.
func solarForecastRates(res map[string]homeassistant.SolarForecast) (api.Rates, error) {
	var slots api.Rates
	bounds := make(map[time.Time]struct{})

	for _, f := range res {
		energy := make(map[time.Time]float64, len(f.WhHours))
		for k, wh := range f.WhHours {
			ts, err := time.Parse(time.RFC3339, k)
			if err != nil {
				return nil, fmt.Errorf("invalid timestamp: %w", err)
			}
			// skip off-grid keys like Forecast.Solar sunrise/sunset that would shrink the slot length
			if !ts.Equal(ts.Truncate(15 * time.Minute)) {
				continue
			}
			energy[ts.Local()] += wh
		}

		ts := slices.SortedFunc(maps.Keys(energy), time.Time.Compare)

		d := time.Hour
		for i := 1; i < len(ts); i++ {
			if delta := ts[i].Sub(ts[i-1]); delta < d {
				d = delta
			}
		}

		for _, t := range ts {
			end := t.Add(d)
			slots = append(slots, api.Rate{Start: t, End: end, Value: energy[t] / d.Hours()})
			bounds[t], bounds[end] = struct{}{}, struct{}{}
		}
	}

	// sum slots of different granularity on the common time grid
	edges := slices.SortedFunc(maps.Keys(bounds), time.Time.Compare)

	// forecasts omit zero-energy periods, the optimizer expects gapless slots
	step := time.Hour
	for i := 0; i+1 < len(edges); i++ {
		step = min(step, edges[i+1].Sub(edges[i]))
	}

	data := make(api.Rates, 0, len(edges))
	for i := 0; i+1 < len(edges); i++ {
		start, end := edges[i], edges[i+1]

		var (
			sum   float64
			found bool
		)
		for _, s := range slots {
			if !s.Start.After(start) && !s.End.Before(end) {
				sum += s.Value
				found = true
			}
		}

		if found {
			data = append(data, api.Rate{Start: start, End: end, Value: sum})
			continue
		}

		for ; start.Before(end); start = start.Add(step) {
			slotEnd := start.Add(step)
			if slotEnd.After(end) {
				slotEnd = end
			}
			data = append(data, api.Rate{Start: start, End: slotEnd})
		}
	}

	return data, nil
}

// zeroFill pads data with zero-energy slots from the given time to the first slot and from the last slot to the given time
func zeroFill(data api.Rates, from, to time.Time) api.Rates {
	if len(data) == 0 {
		return data
	}

	first, last := data[0], data[len(data)-1]

	var pre api.Rates
	for end := first.Start; end.After(from); {
		start := end.Add(-first.End.Sub(first.Start))
		if start.Before(from) {
			start = from
		}
		pre = append(pre, api.Rate{Start: start, End: end})
		end = start
	}
	slices.Reverse(pre)
	data = append(pre, data...)

	step := last.End.Sub(last.Start)
	for start := last.End; start.Before(to); start = start.Add(step) {
		end := start.Add(step)
		if end.After(to) {
			end = to
		}
		data = append(data, api.Rate{Start: start, End: end})
	}

	return data
}

// Rates implements the api.Tariff interface
func (t *HomeAssistant) Rates() (api.Rates, error) {
	var res api.Rates
	err := t.data.GetFunc(func(val api.Rates) {
		res = slices.Clone(val)
	})
	return res, err
}

// Type implements the api.Tariff interface
func (t *HomeAssistant) Type() api.TariffType {
	return api.TariffTypeSolar
}