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
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:
parent
d1ec6f63b9
commit
986d73489d
17 changed files with 414 additions and 32 deletions
20
README.md
20
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -129,3 +129,7 @@ type VehicleStartCharge interface {
|
|||
type VehicleStopCharge interface {
|
||||
StopCharge() error
|
||||
}
|
||||
|
||||
type Tariff interface {
|
||||
IsCheap() bool
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
25
cmd/setup.go
25
cmd/setup.go
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
12
core/site.go
12
core/site.go
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
1
go.mod
|
|
@ -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
2
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
77
tariff/awattar.go
Normal 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
38
tariff/awattar/api.go
Normal 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
24
tariff/config.go
Normal 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
26
tariff/fixed.go
Normal 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
113
tariff/tibber.go
Normal 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
37
tariff/tibber/api.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue