Add Awattar and Tibber (#1169)
Some checks failed
Build / Build (push) Failing after 6m48s
Build / Build UI (push) Failing after 1m29s
Validate Schema / build (push) Failing after 3s
Build / Publish :latest (push) Has been skipped
Build / Publish :release (push) Has been skipped
Build / Release (push) Has been skipped

This commit is contained in:
andig 2021-07-24 11:53:05 +02:00 • committed by GitHub
parent d1ec6f63b9
commit 986d73489d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 414 additions and 32 deletions

View file

@ -36,6 +36,7 @@ EVCC is an extensible EV Charge Controller with PV integration implemented in [G
- [Meter](#meter)
- [Vehicle](#vehicle)
- [Home Energy Management System](#home-energy-management-system)
- [Flexible Energy Tariffs](#flexible-energy-tariffs)
- [Plugins](#plugins)
- [Modbus (read/write)](#modbus-readwrite)
- [MQTT (read/write)](#mqtt-readwrite)
@ -295,6 +296,25 @@ to the configuration. The EVCC loadpoints can then be added to the SHM configura
Sunny-Portal via the "Optional energy demand" slider. When the amount of configured PV is not available, charging suspends like in **PV** mode. So, pushing the slider completely
to the left makes **Min+PV** behave as described above. Pushing completely to the right makes **Min+PV** mode behave like **PV** mode.
### Flexible Energy Tariffs
EVCC supports flexible energy tariffs as offered by [Awattar](https://www.awattar.de) or [Tibber](https://tibber.com). Configuration allows to define a "cheap" rate at which charging from grid is enabled at highest possible rate even when not enough PV power is locally available:
```yaml
tariffs:
grid:
# either
type: tibber
cheap: 20 # ct/kWh
token: "476c477d8a039529478ebd690d35ddd80e3308ffc49b59c65b142321aee963a4" # access token
homeid: "cc83e83e-8cbf-4595-9bf7-c3cf192f7d9c" # optional if multiple homes associated to account
# or
type: awattar
cheap: 20 # ct/kWh
region: de # optional, chose at for Austria
```
## Plugins
Plugins are used to integrate various devices and external data sources with EVCC. Plugins can be used in combination with a `custom` type meter, charger or vehicle.

View file

@ -129,3 +129,7 @@ type VehicleStartCharge interface {
type VehicleStopCharge interface {
StopCharge() error
}
type Tariff interface {
IsCheap() bool
}

View file

@ -30,6 +30,7 @@ type config struct {
Meters []qualifiedConfig
Chargers []qualifiedConfig
Vehicles []qualifiedConfig
Tariffs tariffConfig
Site map[string]interface{}
LoadPoints []map[string]interface{}
}
@ -61,6 +62,10 @@ type messagingConfig struct {
Services []typedConfig
}
type tariffConfig struct {
Grid typedConfig
}
// ConfigProvider provides configuration items
type ConfigProvider struct {
meters map[string]api.Meter

View file

@ -8,6 +8,7 @@ import (
"strconv"
"time"
"github.com/andig/evcc/api"
"github.com/andig/evcc/api/proto/pb"
"github.com/andig/evcc/core"
"github.com/andig/evcc/hems"
@ -15,6 +16,7 @@ import (
"github.com/andig/evcc/provider/mqtt"
"github.com/andig/evcc/push"
"github.com/andig/evcc/server"
"github.com/andig/evcc/tariff"
"github.com/andig/evcc/util"
"github.com/andig/evcc/util/cloud"
"github.com/andig/evcc/util/pipe"
@ -165,21 +167,38 @@ func configureMessengers(conf messagingConfig, cache *util.Cache) chan push.Even
return notificationChan
}
func configureTariffs(conf tariffConfig) (t api.Tariff, err error) {
if conf.Grid.Type != "" {
t, err = tariff.NewFromConfig(conf.Grid.Type, conf.Grid.Other)
}
if err != nil {
err = fmt.Errorf("failed configuring tariff: %w", err)
}
return t, err
}
func configureSiteAndLoadpoints(conf config) (site *core.Site, err error) {
if err = cp.configure(conf); err == nil {
var loadPoints []*core.LoadPoint
loadPoints, err = configureLoadPoints(conf, cp)
var tariff api.Tariff
if err == nil {
site, err = configureSite(conf.Site, cp, loadPoints)
tariff, err = configureTariffs(conf.Tariffs)
}
if err == nil {
site, err = configureSite(conf.Site, cp, loadPoints, tariff)
}
}
return site, err
}
func configureSite(conf map[string]interface{}, cp *ConfigProvider, loadPoints []*core.LoadPoint) (*core.Site, error) {
site, err := core.NewSiteFromConfig(log, cp, conf, loadPoints)
func configureSite(conf map[string]interface{}, cp *ConfigProvider, loadPoints []*core.LoadPoint, tariff api.Tariff) (*core.Site, error) {
site, err := core.NewSiteFromConfig(log, cp, conf, loadPoints, tariff)
if err != nil {
return nil, fmt.Errorf("failed configuring site: %w", err)
}

View file

@ -380,14 +380,6 @@ func (lp *LoadPoint) evChargeCurrentWrappedMeterHandler(current float64) {
// if disabled we cannot be charging
power = 0
}
// TODO
// else if power > 0 && lp.Site.pvMeter != nil {
// // limit charge power to generation plus grid consumption/ minus grid delivery
// // as the charger cannot have consumed more than that
// // consumedPower := consumedPower(lp.pvPower, lp.batteryPower, lp.gridPower)
// consumedPower := lp.Site.consumedPower()
// power = math.Min(power, consumedPower)
// }
// handler only called if charge meter was replaced by dummy
lp.chargeMeter.(*wrapper.ChargeMeter).SetPower(power)
@ -992,7 +984,7 @@ func (lp *LoadPoint) publishSoCAndRange() {
}
// Update is the main control function. It reevaluates meters and charger state
func (lp *LoadPoint) Update(sitePower float64) {
func (lp *LoadPoint) Update(sitePower float64, cheap bool) {
mode := lp.GetMode()
lp.publish("mode", mode)
@ -1082,6 +1074,13 @@ func (lp *LoadPoint) Update(sitePower float64) {
required = true
}
// tariff
if cheap {
targetCurrent = lp.GetMaxCurrent()
lp.log.DEBUG.Printf("cheap tariff: %.3gA", targetCurrent)
required = true
}
// Sunny Home Manager
if lp.remoteControlled(RemoteSoftDisable) {
remoteDisabled = RemoteSoftDisable

View file

@ -168,7 +168,7 @@ func TestUpdatePowerZero(t *testing.T) {
}
lp.Mode = tc.mode
lp.Update(0) // sitePower 0
lp.Update(0, false) // sitePower 0
ctrl.Finish()
}
@ -404,7 +404,7 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) {
charger.EXPECT().Status().Return(api.StatusC, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
lp.Update(500)
lp.Update(500, false)
t.Log("charging above target - soc deactivates charger")
clock.Add(5 * time.Minute)
@ -412,20 +412,20 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) {
charger.EXPECT().Status().Return(api.StatusC, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Enable(false).Return(nil)
lp.Update(500)
lp.Update(500, false)
t.Log("deactivated charger changes status to B")
clock.Add(5 * time.Minute)
vehicle.EXPECT().SoC().Return(95.0, nil)
charger.EXPECT().Status().Return(api.StatusB, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
lp.Update(-5000)
lp.Update(-5000, false)
t.Log("soc has fallen below target - soc update prevented by timer")
clock.Add(5 * time.Minute)
charger.EXPECT().Status().Return(api.StatusB, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
lp.Update(-5000)
lp.Update(-5000, false)
t.Log("soc has fallen below target - soc update timer expired")
clock.Add(pollInterval)
@ -433,7 +433,7 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) {
charger.EXPECT().Status().Return(api.StatusB, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Enable(true).Return(nil)
lp.Update(-5000)
lp.Update(-5000, false)
ctrl.Finish()
}
@ -473,14 +473,14 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
lp.Update(500)
lp.Update(500, false)
t.Log("switch off when disconnected")
clock.Add(5 * time.Minute)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusA, nil)
charger.EXPECT().Enable(false).Return(nil)
lp.Update(-3000)
lp.Update(-3000, false)
if lp.Mode != api.ModeOff {
t.Error("unexpected mode", lp.Mode)
@ -541,14 +541,14 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(0.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1)
lp.Update(-1, false)
t.Log("at 1:00h charging at 5 kWh")
clock.Add(time.Hour)
rater.EXPECT().ChargedEnergy().Return(5.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1)
lp.Update(-1, false)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:00h stop charging at 5 kWh")
@ -556,7 +556,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(5.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusB, nil)
lp.Update(-1)
lp.Update(-1, false)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:00h restart charging at 5 kWh")
@ -564,7 +564,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(5.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1)
lp.Update(-1, false)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:30h continue charging at 7.5 kWh")
@ -572,7 +572,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(7.5, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1)
lp.Update(-1, false)
expectCache("chargedEnergy", 7500.0)
t.Log("at 2:00h stop charging at 10 kWh")
@ -580,7 +580,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(10.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusB, nil)
lp.Update(-1)
lp.Update(-1, false)
expectCache("chargedEnergy", 10000.0)
ctrl.Finish()

View file

@ -17,7 +17,7 @@ import (
// Updater abstracts the LoadPoint implementation for testing
type Updater interface {
Update(float64)
Update(float64, bool)
}
// Site is the main configuration container. A site can host multiple loadpoints.
@ -42,6 +42,7 @@ type Site struct {
pvMeter api.Meter // PV generation meter
batteryMeter api.Meter // Battery charging meter
tariff api.Tariff // Tariff
loadpoints []*LoadPoint // Loadpoints
// cached state
@ -63,6 +64,7 @@ func NewSiteFromConfig(
cp configProvider,
other map[string]interface{},
loadpoints []*LoadPoint,
tariff api.Tariff,
) (*Site, error) {
site := NewSite()
if err := util.DecodeOther(other, &site); err != nil {
@ -70,6 +72,7 @@ func NewSiteFromConfig(
}
Voltage = site.Voltage
site.tariff = tariff
site.loadpoints = loadpoints
if site.Meters.GridMeterRef != "" {
@ -314,8 +317,13 @@ func (site *Site) sitePower() (float64, error) {
func (site *Site) update(lp Updater) {
site.log.DEBUG.Println("----")
var cheap bool
if site.tariff != nil {
cheap = site.tariff.IsCheap()
}
if sitePower, err := site.sitePower(); err == nil {
lp.Update(sitePower)
lp.Update(sitePower, cheap)
site.Health.Update()
}
}

View file

@ -53,6 +53,15 @@ vehicles:
vin: WREN...
cache: 5m
# tariffs are the fixed or variable tariffs
# cheap can be used to define a tariff rate considered cheap enough for charging
tariffs:
grid:
type: tibber # or awattar or fixed
cheap: 20 # ct/kWh
token: "476c477d8a039529478ebd690d35ddd80e3308ffc49b59c65b142321aee963a4"
homeid: "cc83e83e-8cbf-4595-9bf7-c3cf192f7d9c"
# site describes the EVU connection, PV and home battery
site:
title: Home # display name for UI

1
go.mod
View file

@ -59,6 +59,7 @@ require (
github.com/prometheus/client_golang v1.11.0
github.com/prometheus/common v0.29.0 // indirect
github.com/robertkrimen/otto v0.0.0-20210614181706-373ff5438452
github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/spf13/cobra v1.1.3

2
go.sum
View file

@ -586,6 +586,8 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a h1:KikTa6HtAK8cS1qjvUvvq4QO21QnwC+EfvB+OAuZ/ZU=
github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.0.5/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=

View file

@ -34,13 +34,13 @@ func (m *MockUpdater) EXPECT() *MockUpdaterMockRecorder {
}
// Update mocks base method.
func (m *MockUpdater) Update(arg0 float64) {
func (m *MockUpdater) Update(arg0 float64, arg1 bool) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Update", arg0)
m.ctrl.Call(m, "Update", arg0, arg1)
}
// Update indicates an expected call of Update.
func (mr *MockUpdaterMockRecorder) Update(arg0 interface{}) *gomock.Call {
func (mr *MockUpdaterMockRecorder) Update(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockUpdater)(nil).Update), arg0)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockUpdater)(nil).Update), arg0, arg1)
}

77
tariff/awattar.go Normal file
View file

@ -0,0 +1,77 @@
package tariff
import (
"fmt"
"strings"
"sync"
"time"
"github.com/andig/evcc/api"
"github.com/andig/evcc/tariff/awattar"
"github.com/andig/evcc/util"
"github.com/andig/evcc/util/request"
)
type Awattar struct {
mux sync.Mutex
log *util.Logger
uri string
cheap float64
data []awattar.PriceInfo
}
var _ api.Tariff = (*Awattar)(nil)
func NewAwattar(other map[string]interface{}) (*Awattar, error) {
cc := struct {
Cheap float64
Region string
}{
Region: "DE",
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
t := &Awattar{
log: util.NewLogger("awattar"),
cheap: cc.Cheap,
uri: fmt.Sprintf(awattar.RegionURI, strings.ToLower(cc.Region)),
}
go t.Run()
return t, nil
}
func (t *Awattar) Run() {
client := request.NewHelper(t.log)
for ; true; <-time.NewTicker(time.Hour).C {
var res awattar.Prices
if err := client.GetJSON(t.uri, &res); err != nil {
t.log.ERROR.Println(err)
continue
}
t.mux.Lock()
t.data = res.Data
t.mux.Unlock()
}
}
func (t *Awattar) IsCheap() bool {
t.mux.Lock()
defer t.mux.Unlock()
for i := len(t.data) - 1; i >= 0; i-- {
pi := t.data[i]
if pi.StartTimestamp.Before(time.Now()) && pi.EndTimestamp.After(time.Now()) {
return pi.Marketprice/10 <= t.cheap // Eur/MWh conversion
}
}
return false
}

38
tariff/awattar/api.go Normal file
View file

@ -0,0 +1,38 @@
package awattar
import (
"encoding/json"
"time"
)
const RegionURI = "https://api.awattar.%s/v1/marketdata"
type Prices struct {
Data []PriceInfo
}
type PriceInfo struct {
StartTimestamp time.Time `json:"start_timestamp"`
EndTimestamp time.Time `json:"end_timestamp"`
Marketprice float64 `json:"marketprice"`
Unit string `json:"unit"`
}
func (p *PriceInfo) UnmarshalJSON(data []byte) error {
var s struct {
StartTimestamp int64 `json:"start_timestamp"`
EndTimestamp int64 `json:"end_timestamp"`
Marketprice float64 `json:"marketprice"`
Unit string `json:"unit"`
}
err := json.Unmarshal(data, &s)
if err == nil {
p.StartTimestamp = time.Unix(s.StartTimestamp/1e3, 0)
p.EndTimestamp = time.Unix(s.EndTimestamp/1e3, 0)
p.Marketprice = s.Marketprice
p.Unit = s.Unit
}
return err
}

24
tariff/config.go Normal file
View file

@ -0,0 +1,24 @@
package tariff
import (
"errors"
"strings"
"github.com/andig/evcc/api"
)
// NewFromConfig creates new HEMS from config
func NewFromConfig(typ string, other map[string]interface{}) (t api.Tariff, err error) {
switch strings.ToLower(typ) {
case "fixed":
t, err = NewFixed(other)
case "awattar":
t, err = NewAwattar(other)
case "tibber":
t, err = NewTibber(other)
default:
return nil, errors.New("unknown tariff: " + typ)
}
return
}

26
tariff/fixed.go Normal file
View file

@ -0,0 +1,26 @@
package tariff
import (
"github.com/andig/evcc/api"
"github.com/andig/evcc/util"
)
type Fixed struct {
Price float64
}
var _ api.Tariff = (*Fixed)(nil)
func NewFixed(other map[string]interface{}) (*Fixed, error) {
cc := Fixed{}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
return &cc, nil
}
func (t *Fixed) IsCheap() bool {
return false
}

113
tariff/tibber.go Normal file
View file

@ -0,0 +1,113 @@
package tariff
import (
"context"
"fmt"
"sync"
"time"
"github.com/andig/evcc/api"
"github.com/andig/evcc/tariff/tibber"
"github.com/andig/evcc/util"
"github.com/andig/evcc/util/request"
"github.com/shurcooL/graphql"
"golang.org/x/oauth2"
)
type Tibber struct {
mux sync.Mutex
log *util.Logger
Token string
HomeID string
Cheap float64
client *graphql.Client
data []tibber.PriceInfo
}
var _ api.Tariff = (*Tibber)(nil)
func NewTibber(other map[string]interface{}) (*Tibber, error) {
t := &Tibber{
log: util.NewLogger("tibber"),
}
if err := util.DecodeOther(other, &t); err != nil {
return nil, err
}
ctx := context.WithValue(
context.Background(),
oauth2.HTTPClient,
request.NewHelper(t.log).Client,
)
client := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: t.Token,
}))
t.client = graphql.NewClient(tibber.URI, client)
if t.HomeID == "" {
var res struct {
Viewer struct {
Homes []tibber.Home
}
}
if err := t.client.Query(context.Background(), &res, nil); err != nil {
return nil, err
}
if len(res.Viewer.Homes) != 1 {
return nil, fmt.Errorf("could not determine home id: %v", res.Viewer.Homes)
}
t.HomeID = res.Viewer.Homes[0].ID
}
go t.Run()
return t, nil
}
func (t *Tibber) Run() {
for ; true; <-time.NewTicker(time.Hour).C {
var res struct {
Viewer struct {
Home struct {
ID string
TimeZone string
CurrentSubscription tibber.Subscription
} `graphql:"home(id: $id)"`
}
}
v := map[string]interface{}{
"id": graphql.ID(t.HomeID),
}
if err := t.client.Query(context.Background(), &res, v); err != nil {
t.log.ERROR.Println(err)
continue
}
t.mux.Lock()
t.data = res.Viewer.Home.CurrentSubscription.PriceInfo.Today
t.mux.Unlock()
}
}
func (t *Tibber) IsCheap() bool {
t.mux.Lock()
defer t.mux.Unlock()
for i := len(t.data) - 1; i >= 0; i-- {
pi := t.data[i]
if pi.StartsAt.Before(time.Now()) {
return pi.Total <= t.Cheap
}
}
return false
}

37
tariff/tibber/api.go Normal file
View file

@ -0,0 +1,37 @@
package tibber
import (
"time"
)
const URI = "https://api.tibber.com/v1-beta/gql"
type Home struct {
ID string
TimeZone string
Address Address
MeteringPointData struct {
GridCompany string
}
}
type Address struct {
Address1, PostalCode, City, Country string
}
type Subscription struct {
ID string
Status string
PriceInfo struct {
Current PriceInfo
Today []PriceInfo
// Tomorrow []PriceInfo
}
}
type PriceInfo struct {
Level string
StartsAt time.Time
Total float64
// Energy, Tax float64
}