diff --git a/api/api.go b/api/api.go
index 3857f09ea..a5a45c26f 100644
--- a/api/api.go
+++ b/api/api.go
@@ -2,7 +2,7 @@ package api
import "time"
-//go:generate mockgen -package mock -destination ../mock/mock.go github.com/andig/evcc/api Charger,Meter,MeterEnergy
+//go:generate mockgen -package mock -destination ../mock/mock_api.go github.com/andig/evcc/api Charger,Meter,MeterEnergy
// ChargeMode are charge modes modeled after OpenWB
type ChargeMode string
diff --git a/assets/index.html b/assets/index.html
index 1db9da356..3e407ff53 100644
--- a/assets/index.html
+++ b/assets/index.html
@@ -35,6 +35,13 @@
+
+
@@ -84,7 +91,7 @@
-
+
+
diff --git a/assets/js/app.js b/assets/js/app.js
index 12ac54027..c1f75fdaf 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -8,57 +8,98 @@ const loc = window.location.href.indexOf("http://localhost/evcc/assets/") === 0
axios.defaults.baseURL = loc.protocol + "//" + loc.hostname + (loc.port ? ":" + loc.port : "") + "/api";
axios.defaults.headers.post['Content-Type'] = 'application/json';
+//
+// Mixins
+//
+
+let formatter = {
+ data: function () {
+ return {
+ fmtLimit: 100,
+ fmtDigits: 1,
+ }
+ },
+ methods: {
+ round: function(num, precision) {
+ var base = 10 ** precision;
+ return (Math.round(num * base) / base).toFixed(precision);
+ },
+ fmt: function (val) {
+ if (val === undefined || val === null) {
+ return 0;
+ }
+ val = Math.abs(val);
+ return val >= this.fmtLimit ? this.round(val / 1e3, this.fmtDigits) : this.round(val, 0);
+ },
+ fmtUnit: function (val) {
+ return Math.abs(val) >= this.fmtLimit ? "k" : "";
+ },
+ fmtDuration: function (d) {
+ if (d <= 0 || d == null) {
+ return '—';
+ }
+ var seconds = "0" + (d % 60);
+ var minutes = "0" + (Math.floor(d / 60) % 60);
+ var hours = "" + Math.floor(d / 3600);
+ if (hours.length < 2) {
+ hours = "0" + hours;
+ }
+ return hours + ":" + minutes.substr(-2) + ":" + seconds.substr(-2);
+ },
+ fmtShortDuration: function (d) {
+ if (d <= 0 || d == null) {
+ return '—';
+ }
+ var minutes = (Math.floor(d / 60) % 60);
+ var hours = Math.floor(d / 3600);
+ var tm;
+ if (hours >= 1) {
+ minutes = "0" + minutes;
+ tm = hours + ":" + minutes.substr(-2) + "h";
+ } else {
+ var seconds = "0" + (d % 60);
+ tm = minutes + ":" + seconds.substr(-2) + "m";
+ }
+ return tm;
+ },
+ }
+}
+
//
// State
//
let store = {
- initialized: false,
state: {
- // configuration
- mode: null,
- soc: null,
- socCapacity: null,
- socTitle: null,
- gridMeter: true,
- pvMeter: true,
- chargeMeter: true,
- phases: null,
- minCurrent: null,
- maxCurrent: null,
- // runtime
- connected: null,
- charging: null,
- gridPower: null,
- pvPower: null,
- chargePower: null,
- chargeDuration: null,
- chargeEstimate: -1,
- chargedEnergy: null,
- socCharge: "—"
+ loadpoints: [],
},
- update: function(msg, force) {
- Object.keys(msg).forEach(function(k) {
- if (force || this[k] !== undefined) {
- this[k] = msg[k];
- } else {
- if (k == "error") {
- toasts.error({message: msg[k]});
- } else if (k == "warn") {
- toasts.warn({message: msg[k]});
- } else {
- console.log("invalid key: " + k);
- }
+ update: function(msg) {
+ let target = this.state;
+ if (msg.loadpoint !== undefined) {
+ while (this.state.loadpoints.length <= msg.loadpoint) {
+ this.state.loadpoints.push({});
}
- }, this.state);
+ target = this.state.loadpoints[msg.loadpoint];
+ }
+
+ Object.keys(msg).forEach(function (k) {
+ if (typeof toasts[k] === "function") {
+ toasts[k]({message: msg[k]})
+ } else {
+ Vue.set(target, k, msg[k]);
+ }
+ });
},
init: function() {
- if (!store.initialized) {
- axios.get("config").then(function(response) {
- store.update(response.data);
- store.initialized = true;
- }).catch(toasts.error);
- }
+ axios.get("config").then(function(msg) {
+ for (let i=0; i= 100 ? (val / 1e3).toFixed(1) : val.toFixed(0);
- },
- fmtUnit: function(val) {
- return Math.abs(val) >= 100 ? "k" : "";
- },
- fmtDuration: function(d) {
- if (d < 0) {
- return '—';
- }
- var seconds = "0" + (d % 60);
- var minutes = "0" + (Math.floor(d / 60) % 60);
- var hours = "" + Math.floor(d / 3600);
- if (hours.length < 2) {
- hours = "0" + hours;
- }
- return hours + ":" + minutes.substr(-2) + ":" + seconds.substr(-2);
- },
connect: function() {
const protocol = loc.protocol == "https:" ? "wss:" : "ws:";
const uri = protocol + "//" + loc.hostname + (loc.port ? ":" + loc.port : "") + "/ws";
@@ -217,6 +217,28 @@ Vue.component("datapanel", {
},
created: function() {
this.connect();
+ }
+});
+
+Vue.component("loadpoint", {
+ template: "#loadpoint-template",
+ props: { state: Object },
+ mixins: [formatter],
+ data: function() {
+ return {
+ tickerHandle: null,
+ };
+ },
+ watch: {
+ "state.chargeDuration": function() {
+ window.clearInterval(this.tickerHandle);
+ // only ticker if actually charging
+ if (this.state.charging && this.state.chargeDuration >= 0) {
+ this.tickerHandle = window.setInterval(function() {
+ this.state.chargeDuration += 1;
+ }.bind(this), 1000);
+ }
+ },
},
destroyed: function() {
window.clearInterval(this.tickerHandle);
diff --git a/charger/charger.go b/charger/charger.go
index 5cd415755..8acbb9435 100644
--- a/charger/charger.go
+++ b/charger/charger.go
@@ -8,10 +8,10 @@ import (
// Charger is an api.Charger implementation with configurable getters and setters.
type Charger struct {
- statusG provider.StringGetter
- enabledG provider.BoolGetter
- enableS provider.BoolSetter
- maxCurrentS provider.IntSetter
+ statusG func() (string, error)
+ enabledG func() (bool, error)
+ enableS func(bool) error
+ maxCurrentS func(int64) error
}
// NewConfigurableFromConfig creates a new configurable charger
@@ -31,10 +31,10 @@ func NewConfigurableFromConfig(log *util.Logger, other map[string]interface{}) a
// NewConfigurable creates a new charger
func NewConfigurable(
- statusG provider.StringGetter,
- enabledG provider.BoolGetter,
- enableS provider.BoolSetter,
- maxCurrentS provider.IntSetter,
+ statusG func() (string, error),
+ enabledG func() (bool, error),
+ enableS func(bool) error,
+ maxCurrentS func(int64) error,
) api.Charger {
return &Charger{
statusG: statusG,
diff --git a/cmd/config.go b/cmd/config.go
index 5b1cb5827..6fc90249e 100644
--- a/cmd/config.go
+++ b/cmd/config.go
@@ -22,6 +22,7 @@ type config struct {
Meters []qualifiedConfig
Chargers []qualifiedConfig
Vehicles []qualifiedConfig
+ Site map[string]interface{}
LoadPoints []map[string]interface{}
}
diff --git a/cmd/config_test.go b/cmd/config_test.go
index 67a20cc4c..39d762c15 100644
--- a/cmd/config_test.go
+++ b/cmd/config_test.go
@@ -42,7 +42,7 @@ func TestDistConfig(t *testing.T) {
}
// check config is valid
- loadConfig(conf, nil)
+ _ = loadConfig(conf)
}
func TestMeterConfig(t *testing.T) {
diff --git a/cmd/root.go b/cmd/root.go
index 2adafb829..521ddcc19 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -141,31 +141,29 @@ func run(cmd *cobra.Command, args []string) {
uri := viper.GetString("uri")
log.INFO.Println("listening at", uri)
- // setup messaging
- notificationChan := configureMessengers(conf.Messaging)
-
// setup mqtt
if viper.Get("mqtt") != nil {
provider.MQTT = provider.NewMqttClient(conf.Mqtt.Broker, conf.Mqtt.User, conf.Mqtt.Password, clientID(), 1)
}
// setup loadpoints
- loadPoints := loadConfig(conf, notificationChan)
+ site := loadConfig(conf)
// start broadcasting values
tee := &Tee{}
+ // value cache
cache := util.NewCache()
go cache.Run(tee.Attach())
// setup database
if conf.Influx.URL != "" {
- configureDatabase(tee.Attach(), conf.Influx)
+ configureDatabase(conf.Influx, site.LoadPoints(), tee.Attach())
}
// create webserver
socketHub := server.NewSocketHub()
- httpd := server.NewHTTPd(uri, conf.Menu, loadPoints[0], socketHub)
+ httpd := server.NewHTTPd(uri, conf.Menu, site, socketHub, cache)
// publish to UI
go socketHub.Run(tee.Attach(), cache)
@@ -177,12 +175,14 @@ func run(cmd *cobra.Command, args []string) {
// capture log messages for UI
util.CaptureLogs(valueChan)
- // start all loadpoints
- for _, lp := range loadPoints {
- lp.Dump()
- lp.Prepare(valueChan, notificationChan)
- go lp.Run(conf.Interval)
- }
+ // setup messaging
+ pushChan := configureMessengers(conf.Messaging, cache)
+
+ // set channels
+ site.Prepare(valueChan, pushChan)
+
+ site.DumpConfig()
+ go site.Run(conf.Interval)
log.FATAL.Println(httpd.ListenAndServe())
}
diff --git a/cmd/setup.go b/cmd/setup.go
index 9cd1e8045..697a08a7a 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"math/rand"
+ "strconv"
"time"
"github.com/andig/evcc/core"
@@ -17,7 +18,7 @@ func init() {
}
// setup influx databases
-func configureDatabase(in <-chan util.Param, conf server.InfluxConfig) {
+func configureDatabase(conf server.InfluxConfig, loadPoints []*core.LoadPoint, in <-chan util.Param) {
influx := server.NewInfluxClient(
conf.URL,
conf.Token,
@@ -35,12 +36,12 @@ func configureDatabase(in <-chan util.Param, conf server.InfluxConfig) {
limiter := server.NewLimiter(5 * time.Second)
in = limiter.Pipe(in)
- go influx.Run(in)
+ go influx.Run(loadPoints, in)
}
-func configureMessengers(conf messagingConfig) chan push.Event {
+func configureMessengers(conf messagingConfig, cache *util.Cache) chan push.Event {
notificationChan := make(chan push.Event, 1)
- notificationHub := push.NewHub(conf.Events)
+ notificationHub := push.NewHub(conf.Events, cache)
for _, service := range conf.Services {
impl := push.NewMessengerFromConfig(service.Type, service.Other)
@@ -57,10 +58,21 @@ func clientID() string {
return fmt.Sprintf("evcc-%d", pid)
}
-func loadConfig(conf config, eventsChan chan push.Event) (loadPoints []*core.LoadPoint) {
+func loadConfig(conf config) *core.Site {
cp := &ConfigProvider{}
cp.configure(conf)
+ loadPoints := configureLoadPoints(conf, cp)
+ site := configureSite(conf.Site, cp, loadPoints)
+
+ return site
+}
+
+func configureSite(conf map[string]interface{}, cp *ConfigProvider, loadPoints []*core.LoadPoint) *core.Site {
+ return core.NewSiteFromConfig(log, cp, conf, loadPoints)
+}
+
+func configureLoadPoints(conf config, cp *ConfigProvider) (loadPoints []*core.LoadPoint) {
// slice of loadpoints
lps, ok := viper.AllSettings()["loadpoints"]
if !ok {
@@ -71,7 +83,8 @@ func loadConfig(conf config, eventsChan chan push.Event) (loadPoints []*core.Loa
var lpc []map[string]interface{}
util.DecodeOther(log, lps, &lpc)
- for _, lpc := range lpc {
+ for id, lpc := range lpc {
+ log := util.NewLogger("lp-" + strconv.Itoa(id+1))
lp := core.NewLoadPointFromConfig(log, cp, lpc)
loadPoints = append(loadPoints, lp)
}
diff --git a/core/api.go b/core/api.go
deleted file mode 100644
index 64361c3bb..000000000
--- a/core/api.go
+++ /dev/null
@@ -1,164 +0,0 @@
-package core
-
-import (
- "time"
-
- "github.com/andig/evcc/api"
- "github.com/andig/evcc/core/wrapper"
-)
-
-// nilVal implements Stringer for nil values
-type nilVal int
-
-func (n *nilVal) String() string {
- return "—"
-}
-
-// Configuration is the loadpoint feature structure
-type Configuration struct {
- Mode string `json:"mode"`
- Phases int64 `json:"phases"`
- MinCurrent int64 `json:"minCurrent"`
- MaxCurrent int64 `json:"maxCurrent"`
- GridMeter bool `json:"gridMeter"`
- PVMeter bool `json:"pvMeter"`
- ChargeMeter bool `json:"chargeMeter"`
- SoC bool `json:"soc"`
- SoCCapacity int64 `json:"socCapacity"`
- SoCTitle string `json:"socTitle"`
-}
-
-// Configuration returns meter configuration
-func (lp *LoadPoint) Configuration() Configuration {
- c := Configuration{
- Mode: string(lp.GetMode()),
- Phases: lp.Phases,
- MinCurrent: lp.MinCurrent,
- MaxCurrent: lp.MaxCurrent,
- GridMeter: lp.gridMeter != nil,
- PVMeter: lp.pvMeter != nil,
- ChargeMeter: lp.hasChargeMeter(),
- }
-
- if lp.vehicle != nil {
- c.SoC = true
- c.SoCCapacity = lp.vehicle.Capacity()
- c.SoCTitle = lp.vehicle.Title()
- }
-
- return c
-}
-
-func (lp *LoadPoint) hasChargeMeter() bool {
- _, isWrapped := lp.chargeMeter.(*wrapper.ChargeMeter)
- return lp.chargeMeter != nil && !isWrapped
-}
-
-// Dump loadpoint configuration
-func (lp *LoadPoint) Dump() {
- log.INFO.Printf("%s loadpoint config: vehicle %s grid %s pv %s battery %s charge %s", lp.Name,
- presence[lp.vehicle != nil],
- presence[lp.gridMeter != nil],
- presence[lp.pvMeter != nil],
- presence[lp.batteryMeter != nil],
- presence[lp.hasChargeMeter()],
- )
-
- _, power := lp.charger.(api.Meter)
- _, energy := lp.charger.(api.ChargeRater)
- _, timer := lp.charger.(api.ChargeTimer)
- log.INFO.Printf("%s charger config: power %s energy %s timer %s", lp.Name,
- presence[power],
- presence[energy],
- presence[timer],
- )
-
- log.INFO.Printf("%s charge mode: %s", lp.Name, lp.GetMode())
-}
-
-// Update triggers loadpoint to run main control loop and push messages to UI socket
-func (lp *LoadPoint) Update() {
- select {
- case lp.triggerChan <- struct{}{}: // non-blocking send
- default:
- log.WARN.Printf("%s update blocked", lp.Name)
- }
-}
-
-// GetMode returns loadpoint charge mode
-func (lp *LoadPoint) GetMode() api.ChargeMode {
- lp.Lock()
- defer lp.Unlock()
-
- return lp.Mode
-}
-
-// SetMode sets loadpoint charge mode
-func (lp *LoadPoint) SetMode(mode api.ChargeMode) {
- lp.Lock()
- defer lp.Unlock()
-
- log.INFO.Printf("%s set charge mode: %s", lp.Name, string(mode))
-
- // apply immediately
- if lp.Mode != mode {
- lp.resetGuard()
- lp.Mode = mode
- }
-
- lp.Update()
-}
-
-// chargeDuration returns for how long the charge cycle has been running
-func (lp *LoadPoint) chargeDuration() time.Duration {
- d, err := lp.chargeTimer.ChargingTime()
- if err != nil {
- log.ERROR.Printf("%s charge timer error: %v", lp.Name, err)
- }
- return d
-}
-
-// chargedEnergy returns energy consumption since charge start in kWh
-func (lp *LoadPoint) chargedEnergy() float64 {
- f, err := lp.chargeRater.ChargedEnergy()
- if err != nil {
- log.ERROR.Printf("%s charge rater error: %v", lp.Name, err)
- }
- return f
-}
-
-// remainingChargeDuration returns the remaining charge time
-func (lp *LoadPoint) remainingChargeDuration(chargePercent float64) time.Duration {
- if !lp.charging {
- return -1
- }
-
- if lp.chargePower > 0 && lp.vehicle != nil {
- whRemaining := (1 - chargePercent/100.0) * 1e3 * float64(lp.vehicle.Capacity())
- return time.Duration(float64(time.Hour) * whRemaining / lp.chargePower)
- }
-
- return -1
-}
-
-// publish state of charge and remaining charge duration
-func (lp *LoadPoint) publishSoC() {
- if lp.vehicle == nil {
- return
- }
-
- if lp.connected() {
- f, err := lp.vehicle.ChargeState()
- if err == nil {
- log.DEBUG.Printf("%s vehicle soc: %.1f%%", lp.Name, f)
- lp.publish("socCharge", f)
- lp.publish("chargeEstimate", lp.remainingChargeDuration(f))
- return
- }
- log.ERROR.Printf("%s vehicle error: %v", lp.Name, err)
- }
-
- var n *nilVal
- lp.publish("socCharge", n)
- lp.publish("chargeEstimate", -1)
-}
diff --git a/core/chargerhandler.go b/core/chargerhandler.go
index 7cce71666..d6bfe827b 100644
--- a/core/chargerhandler.go
+++ b/core/chargerhandler.go
@@ -5,65 +5,109 @@ import (
"time"
"github.com/andig/evcc/api"
+ "github.com/andig/evcc/util"
evbus "github.com/asaskevich/EventBus"
"github.com/benbjohnson/clock"
)
-// ChargerHandler handles steering of the charger state and allowed current
-type ChargerHandler struct {
- clock clock.Clock // mockable time
- bus evbus.Bus // event bus
-
- charger api.Charger // Charger
-
- Name string
+// HandlerConfig contains the public configuration for the ChargerHandler
+type HandlerConfig struct {
Sensitivity int64 // Step size of current change
MinCurrent int64 // PV mode: start current Min+PV mode: min current
MaxCurrent int64 // Max allowed current. Physically ensured by the charge controller
GuardDuration time.Duration // charger enable/disable minimum holding time
+}
- enabled bool // Charger enabled state
- targetCurrent int64
+// ChargerHandler handles steering of the charger state and allowed current
+type ChargerHandler struct {
+ clock clock.Clock // mockable time
+ bus evbus.Bus // event bus
+ log *util.Logger
+
+ charger api.Charger // Charger
+
+ HandlerConfig // public configuration
+
+ enabled bool // Charger enabled state
+ targetCurrent int64 // Charger target current
// contactor switch guard
guardUpdated time.Time // charger enabled/disabled timestamp
}
-// NewChargerHandler creates handler with default settings
-func NewChargerHandler(name string, clock clock.Clock, bus evbus.Bus) ChargerHandler {
- return ChargerHandler{
- Name: name,
- clock: clock, // mockable time
- bus: bus, // event bus
- MinCurrent: 6, // A
- MaxCurrent: 16, // A
- Sensitivity: 10, // A
- GuardDuration: 5 * time.Minute,
+// Status returns charger status
+func (lp *ChargerHandler) Status() (api.ChargeStatus, error) {
+ return lp.charger.Status()
+}
+
+// Enabled returns handler enabled state
+func (lp *ChargerHandler) Enabled() bool {
+ return lp.enabled
+}
+
+// TargetCurrent returns handler target current
+func (lp *ChargerHandler) TargetCurrent() int64 {
+ return lp.targetCurrent
+}
+
+// Prepare synchronizes initial charger enabled state and current
+func (lp *ChargerHandler) Prepare() {
+ // read initial enabled state
+ enabled, err := lp.charger.Enabled()
+ if err == nil {
+ lp.enabled = enabled
+ lp.log.INFO.Printf("charger %sd", status[lp.enabled])
+
+ // prevent immediately disabling charger
+ if lp.enabled {
+ lp.guardUpdated = lp.clock.Now()
+ }
+ } else {
+ lp.log.ERROR.Printf("charger error: %v", err)
+ }
+
+ // set current to known value
+ if err = lp.setTargetCurrent(lp.MinCurrent); err != nil {
+ lp.log.ERROR.Println(err)
+ }
+ lp.bus.Publish(evChargeCurrent, lp.MinCurrent)
+}
+
+// SyncSettings synchronizes charger settings to expected state
+func (lp *ChargerHandler) SyncSettings() {
+ enabled, err := lp.charger.Enabled()
+ if err == nil && enabled != lp.enabled {
+ lp.log.DEBUG.Printf("sync enabled state to %s", status[lp.enabled])
+ err = lp.charger.Enable(lp.enabled)
+ }
+
+ if err != nil {
+ lp.log.ERROR.Printf("charge controller error: %v", err)
}
}
// chargerEnable switches charging on or off. Minimum cycle duration is guaranteed.
func (lp *ChargerHandler) chargerEnable(enable bool) error {
if lp.targetCurrent != 0 && lp.targetCurrent != lp.MinCurrent {
- log.FATAL.Fatal("charger enable/disable called without setting min current first")
+ lp.log.FATAL.Fatal("charger enable/disable called without setting min current first")
}
if remaining := (lp.GuardDuration - lp.clock.Since(lp.guardUpdated)).Truncate(time.Second); remaining > 0 {
- log.DEBUG.Printf("%s charger %s - contactor delay %v", lp.Name, status[enable], remaining)
+ lp.log.DEBUG.Printf("charger %s - contactor delay %v", status[enable], remaining)
return nil
}
if lp.enabled != enable {
if err := lp.charger.Enable(enable); err != nil {
- return fmt.Errorf("%s charge controller error: %v", lp.Name, err)
+ return fmt.Errorf("charge controller error: %v", err)
}
lp.enabled = enable // cache
- log.INFO.Printf("%s charger %s", lp.Name, status[enable])
+ lp.log.INFO.Printf("charger %s", status[enable])
lp.guardUpdated = lp.clock.Now()
} else {
- log.DEBUG.Printf("%s charger %s", lp.Name, status[enable])
+ lp.log.DEBUG.Printf("charger %s", status[enable])
}
// if not enabled, current will be reduced to 0 in handler
@@ -77,18 +121,19 @@ func (lp *ChargerHandler) chargerEnable(enable bool) error {
func (lp *ChargerHandler) setTargetCurrent(targetCurrentIn int64) error {
targetCurrent := clamp(targetCurrentIn, lp.MinCurrent, lp.MaxCurrent)
if targetCurrent != targetCurrentIn {
- log.WARN.Printf("%s hard limit charge current: %dA", lp.Name, targetCurrent)
+ lp.log.WARN.Printf("hard limit charge current: %dA", targetCurrent)
}
if lp.targetCurrent != targetCurrent {
- log.DEBUG.Printf("%s set charge current: %dA", lp.Name, targetCurrent)
+ lp.log.DEBUG.Printf("set charge current: %dA", targetCurrent)
if err := lp.charger.MaxCurrent(targetCurrent); err != nil {
- return fmt.Errorf("%s charge controller error: %v", lp.Name, err)
+ return fmt.Errorf("charge controller error: %v", err)
}
lp.targetCurrent = targetCurrent // cache
}
+ // if not enabled, current will be reduced to 0 in handler
lp.bus.Publish(evChargeCurrent, targetCurrent)
return nil
@@ -141,3 +186,24 @@ func (lp *ChargerHandler) rampOn(target int64) error {
return lp.setTargetCurrent(target)
}
+
+// Ramp performs ramping charger current up and down where targetCurrent=0
+// signals disabled state
+func (lp *ChargerHandler) Ramp(targetCurrent int64, force ...bool) error {
+ // reset guard updated
+ if len(force) == 1 && force[0] {
+ lp.guardUpdated = time.Time{}
+ }
+
+ // if targetCurrent == 0 ramp down to disabled state
+ if targetCurrent == 0 {
+ return lp.rampOff()
+ }
+
+ // targetCurrent != 0 and not enabled ramp to enabled state
+ if !lp.enabled {
+ return lp.rampOn(targetCurrent)
+ }
+
+ return lp.rampUpDown(targetCurrent)
+}
diff --git a/core/chargerhandler_test.go b/core/chargerhandler_test.go
index 48ff58868..32e9ea8cb 100644
--- a/core/chargerhandler_test.go
+++ b/core/chargerhandler_test.go
@@ -6,30 +6,46 @@ import (
"github.com/andig/evcc/api"
"github.com/andig/evcc/mock"
+ "github.com/andig/evcc/util"
evbus "github.com/asaskevich/EventBus"
"github.com/benbjohnson/clock"
"github.com/golang/mock/gomock"
)
const (
- minA int64 = 6
- maxA int64 = 16
- sensitivity = 1
- dt = time.Hour
+ minA int64 = 6
+ maxA int64 = 16
+ sensitivity = 1
+ guardDuration = 5 * time.Minute
+ dt = time.Hour
)
-func newChargerHandler(clock clock.Clock, mc api.Charger) ChargerHandler {
- r := NewChargerHandler("", clock, evbus.New())
+func newChargerHandler(clock clock.Clock, mc api.Charger) *ChargerHandler {
+ h := &ChargerHandler{
+ log: util.NewLogger("foo"),
+ clock: clock,
+ bus: evbus.New(),
+ charger: mc,
+ HandlerConfig: HandlerConfig{
+ MinCurrent: minA,
+ MaxCurrent: maxA,
+ Sensitivity: sensitivity,
+ GuardDuration: guardDuration,
+ },
+ }
- r.charger = mc
- r.Sensitivity = sensitivity
- r.guardUpdated = clock.Now()
+ // prepare charger and set guardUpdated
+ mc.(*mock.MockCharger).EXPECT().Enabled().Return(true, nil)
+ mc.(*mock.MockCharger).EXPECT().MaxCurrent(int64(6)).Return(nil)
+ h.Prepare()
- return r
+ return h
}
+// test here to ensure loadpoint defaults are valid
func TestNewChargerHandler(t *testing.T) {
- r := NewChargerHandler("", nil, nil)
+ // LoadPoint contains ChargerHandler configuration
+ r := NewLoadPoint(util.NewLogger("foo"))
if r.MinCurrent != minA {
t.Errorf("expected %v, got %v", minA, r.MinCurrent)
@@ -40,18 +56,18 @@ func TestNewChargerHandler(t *testing.T) {
if r.Sensitivity != 10 {
t.Errorf("expected %v, got %v", 10, r.Sensitivity)
}
- if r.GuardDuration != 5*time.Minute {
- t.Errorf("expected %v, got %v", 5*time.Minute, r.GuardDuration)
+ if r.GuardDuration != guardDuration {
+ t.Errorf("expected %v, got %v", guardDuration, r.GuardDuration)
}
}
func TestEnable(t *testing.T) {
tc := []struct {
- enabledI bool
- dt time.Duration
- enable bool
- targetCurrentI int64
- expect func(*mock.MockCharger)
+ enabled bool
+ dt time.Duration
+ enable bool
+ targetCurrent int64
+ expect func(*mock.MockCharger)
}{
// any test with current != 0 or min will fail
{false, 0, false, 0, func(mc *mock.MockCharger) {
@@ -91,8 +107,8 @@ func TestEnable(t *testing.T) {
clock := clock.NewMock()
r := newChargerHandler(clock, mc)
- r.enabled = tc.enabledI
- r.targetCurrent = tc.targetCurrentI
+ r.enabled = tc.enabled
+ r.targetCurrent = tc.targetCurrent
tc.expect(mc)
clock.Add(tc.dt)
@@ -317,13 +333,13 @@ func TestRampUpDown(t *testing.T) {
t.Log(tc)
clock := clock.NewMock()
- r := newChargerHandler(clock, mc)
- r.enabled = true
- r.targetCurrent = tc.targetCurrentI
+ h := newChargerHandler(clock, mc)
+ h.enabled = true
+ h.targetCurrent = tc.targetCurrentI
tc.expect(mc)
- if err := r.rampUpDown(tc.targetCurrent); err != nil {
+ if err := h.rampUpDown(tc.targetCurrent); err != nil {
t.Error(err)
}
diff --git a/core/config.go b/core/config.go
new file mode 100644
index 000000000..b126cff2d
--- /dev/null
+++ b/core/config.go
@@ -0,0 +1,10 @@
+package core
+
+import "github.com/andig/evcc/api"
+
+// configProvider gives access to configuration repository
+type configProvider interface {
+ Meter(string) api.Meter
+ Charger(string) api.Charger
+ Vehicle(string) api.Vehicle
+}
diff --git a/core/helper.go b/core/helper.go
new file mode 100644
index 000000000..778749df4
--- /dev/null
+++ b/core/helper.go
@@ -0,0 +1,30 @@
+package core
+
+import "github.com/avast/retry-go"
+
+var (
+ status = map[bool]string{false: "disable", true: "enable"}
+ presence = map[bool]string{false: "—", true: "✓"}
+
+ // retryOptions ist the default options set for retryable operations
+ retryOptions = []retry.Option{retry.Attempts(3), retry.LastErrorOnly(true)}
+
+ // Voltage global value
+ Voltage float64
+)
+
+// powerToCurrent is a helper function to convert power to per-phase current
+func powerToCurrent(power, voltage float64, phases int64) int64 {
+ return int64(power / (float64(phases) * voltage))
+}
+
+// consumedPower estimates how much power the charger might have consumed given it was the only load
+// func consumedPower(pv, battery, grid float64) float64 {
+// return math.Abs(pv) + battery + grid
+// }
+
+// sitePower returns the available delta power that the charger might additionally consume
+// negative value: available power (grid export), positive value: grid import
+func sitePower(grid, battery, residual float64) float64 {
+ return grid + battery + residual
+}
diff --git a/core/loadpoint.go b/core/loadpoint.go
index cbe268656..3b8199e0f 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -1,8 +1,6 @@
package core
import (
- "math"
- "sync"
"time"
"github.com/andig/evcc/api"
@@ -16,239 +14,129 @@ import (
"github.com/benbjohnson/clock"
)
-var (
- status = map[bool]string{false: "disable", true: "enable"}
- presence = map[bool]string{false: "—", true: "✓"}
-)
-
const (
- evStartCharge = "start" // update chargeTimer
- evStopCharge = "stop" // update chargeTimer
+ evChargeStart = "start" // update chargeTimer
+ evChargeStop = "stop" // update chargeTimer
evChargeCurrent = "current" // update fakeChargeMeter
evChargePower = "power" // update chargeRater
)
-// powerToCurrent is a helper function to convert power to per-phase current
-func powerToCurrent(power, voltage float64, phases int64) int64 {
- return int64(power / (float64(phases) * voltage))
-}
-
-// Config contains the public loadpoint configuration
-type Config struct {
- Mode api.ChargeMode // Charge mode, guarded by mutex
-
- // options
- Phases int64 // Phases- required for converting power and current.
- Voltage float64 // Operating voltage. 230V for Germany.
- ResidualPower float64 // PV meter only: household usage. Grid meter: household safety margin
-
- ChargerRef string `mapstructure:"charger"` // Charger reference
- VehicleRef string `mapstructure:"vehicle"` // Vehicle reference
-
- Meters MetersConfig // Meter references
-
- Enable, Disable ThresholdConfig
-}
-
// ThresholdConfig defines enable/disable hysteresis parameters
type ThresholdConfig struct {
Delay time.Duration
Threshold float64
}
-// MetersConfig contains the loadpoint's meter configuration
-type MetersConfig struct {
- GridMeterRef string `mapstructure:"grid"` // Grid usage meter reference
- ChargeMeterRef string `mapstructure:"charge"` // Charger usage meter reference
- PVMeterRef string `mapstructure:"pv"` // PV generation meter reference
- BatteryMeterRef string `mapstructure:"battery"` // Battery charging meter reference
+//go:generate mockgen -package mock -destination ../mock/mock_chargerhandler.go github.com/andig/evcc/core Handler
+
+// Handler is the charger handler responsible for enabled state, target current and guard durations
+type Handler interface {
+ Prepare()
+ SyncSettings()
+ Enabled() bool
+ Status() (api.ChargeStatus, error)
+ TargetCurrent() int64
+ Ramp(int64, ...bool) error
}
// LoadPoint is responsible for controlling charge depending on
// SoC needs and power availability.
type LoadPoint struct {
- sync.Mutex // guard status
- clock clock.Clock // mockable time
- bus evbus.Bus // event bus
- triggerChan chan struct{} // API updates
- notificationChan chan<- push.Event // notifications
- uiChan chan<- util.Param // client push messages
+ clock clock.Clock // mockable time
+ bus evbus.Bus // event bus
+ pushChan chan<- push.Event // notifications
+ uiChan chan<- util.Param // client push messages
+ log *util.Logger
- Config `mapstructure:",squash"` // exposed public configuration
- ChargerHandler `mapstructure:",squash"` // handle charger state and current
+ // exposed public configuration
+ Title string `mapstructure:"title"` // UI title
+ Phases int64 `mapstructure:"phases"` // Phases- required for converting power and current
+ ChargerRef string `mapstructure:"charger"` // Charger reference
+ VehicleRef string `mapstructure:"vehicle"` // Vehicle reference
+ Meters struct {
+ ChargeMeterRef string `mapstructure:"charge"` // Charge meter reference
+ }
+ Enable, Disable ThresholdConfig
+
+ handler Handler
+ HandlerConfig `mapstructure:",squash"` // handle charger state and current
chargeTimer api.ChargeTimer
chargeRater api.ChargeRater
- // meters
- gridMeter api.Meter // Grid usage meter
- pvMeter api.Meter // PV generation meter
- batteryMeter api.Meter // Battery charging meter
- chargeMeter api.Meter // Charger usage meter
- vehicle api.Vehicle // Vehicle
+ chargeMeter api.Meter // Charger usage meter
+ vehicle api.Vehicle // Vehicle
// cached state
- status api.ChargeStatus // Charger status
- charging bool // Charging cycle
- gridPower float64 // Grid power
- pvPower float64 // PV power
- batteryPower float64 // Battery charge power
- chargePower float64 // Charging power
+ status api.ChargeStatus // Charger status
+ charging bool // Charging cycle
+ chargePower float64 // Charging power
+ sitePower float64 // Available power from site
pvTimer time.Time
}
-// configProvider gives access to configuration repository
-type configProvider interface {
- Meter(string) api.Meter
- Charger(string) api.Charger
- Vehicle(string) api.Vehicle
-}
-
// NewLoadPointFromConfig creates a new loadpoint
func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[string]interface{}) *LoadPoint {
- lp := NewLoadPoint()
+ lp := NewLoadPoint(log)
util.DecodeOther(log, other, &lp)
- // workaround mapstructure
- if lp.Mode == "0" {
- lp.Mode = api.ModeOff
- }
-
- if lp.ChargerRef != "" {
- lp.charger = cp.Charger(lp.ChargerRef)
- } else {
- log.FATAL.Fatal("config: missing charger")
- }
- if lp.Meters.PVMeterRef == "" && lp.Meters.GridMeterRef == "" {
- log.FATAL.Fatal("config: missing either pv or grid meter")
- }
- if lp.Meters.GridMeterRef != "" {
- lp.gridMeter = cp.Meter(lp.Meters.GridMeterRef)
- }
if lp.Meters.ChargeMeterRef != "" {
lp.chargeMeter = cp.Meter(lp.Meters.ChargeMeterRef)
}
- if lp.Meters.PVMeterRef != "" {
- lp.pvMeter = cp.Meter(lp.Meters.PVMeterRef)
- }
- if lp.Meters.BatteryMeterRef != "" {
- lp.batteryMeter = cp.Meter(lp.Meters.BatteryMeterRef)
- }
if lp.VehicleRef != "" {
lp.vehicle = cp.Vehicle(lp.VehicleRef)
}
+ if lp.ChargerRef == "" {
+ lp.log.FATAL.Fatal("config: missing charger")
+ }
+ charger := cp.Charger(lp.ChargerRef)
+ lp.configureChargerType(charger)
+
+ lp.handler = &ChargerHandler{
+ log: lp.log,
+ clock: lp.clock,
+ bus: lp.bus,
+ charger: charger,
+ HandlerConfig: lp.HandlerConfig,
+ }
+
return lp
}
// NewLoadPoint creates a LoadPoint with sane defaults
-func NewLoadPoint() *LoadPoint {
+func NewLoadPoint(log *util.Logger) *LoadPoint {
clock := clock.New()
bus := evbus.New()
lp := &LoadPoint{
- clock: clock, // mockable time
- bus: bus, // event bus
- triggerChan: make(chan struct{}, 1),
- Config: Config{
- Mode: api.ModeOff,
- Phases: 1,
- Voltage: 230, // V
+ log: log, // logger
+ clock: clock, // mockable time
+ bus: bus, // event bus
+ Phases: 1,
+ status: api.StatusNone,
+ HandlerConfig: HandlerConfig{
+ MinCurrent: 6, // A
+ MaxCurrent: 16, // A
+ Sensitivity: 10, // A
+ GuardDuration: 5 * time.Minute,
},
- status: api.StatusNone,
- ChargerHandler: NewChargerHandler("main", clock, bus),
}
return lp
}
-// notify sends push messages to clients
-func (lp *LoadPoint) notify(event string, attributes map[string]interface{}) {
- attributes["loadpoint"] = lp.Name
- lp.notificationChan <- push.Event{
- Event: event,
- Attributes: attributes,
- }
-}
-
-// publish sends values to UI and databases
-func (lp *LoadPoint) publish(key string, val interface{}) {
- lp.uiChan <- util.Param{
- LoadPoint: lp.Name,
- Key: key,
- Val: val,
- }
-}
-
-// evChargeStartHandler sends external start event
-func (lp *LoadPoint) evChargeStartHandler() {
- lp.notify(evStartCharge, map[string]interface{}{
- "mode": lp.GetMode(),
- })
-}
-
-// evChargeStartHandler sends external stop event
-func (lp *LoadPoint) evChargeStopHandler() {
- energy, err := lp.chargeRater.ChargedEnergy()
- if err != nil {
- log.ERROR.Printf("%s charged energy: %v", lp.Name, err)
- }
-
- duration, err := lp.chargeTimer.ChargingTime()
- if err != nil {
- log.ERROR.Printf("%s charge duration: %v", lp.Name, err)
- }
-
- lp.notify(evStopCharge, map[string]interface{}{
- "energy": energy,
- "duration": duration.Truncate(time.Second),
- })
-}
-
-// consumedPower estimates how much power the charger might have consumed given it was the only load
-// negative values mean pv: production, battery: charging, grid: export
-func consumedPower(pv, battery, grid float64) float64 {
- return math.Abs(pv) + battery + grid
-}
-
-// evChargeCurrentHandler updates the dummy charge meter's charge power. This simplifies the main flow
-// where the charge meter can always be treated as present. It assumes that the charge meter cannot consume
-// more than total household consumption. If physical charge meter is present this handler is not used.
-func (lp *LoadPoint) evChargeCurrentHandler(current int64) {
- power := float64(current*lp.Phases) * lp.Voltage
-
- if !lp.enabled || lp.status != api.StatusC {
- // if disabled we cannot be charging
- power = 0
- } else if power > 0 && lp.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)
- power = math.Min(power, consumedPower)
- }
-
- // handler only called if charge meter was replaced by dummy
- lp.chargeMeter.(*wrapper.ChargeMeter).SetPower(power)
-}
-
-// Prepare loadpoint configuration by adding missing helper elements
-func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, notificationChan chan<- push.Event) {
- lp.notificationChan = notificationChan
- lp.uiChan = uiChan
-
- if lp.pvMeter == nil && lp.gridMeter == nil {
- log.FATAL.Fatal("missing either pv or grid meter")
- }
-
+// configureChargerType ensures that chargeMeter, Rate and Timer can use charger capabilities
+func (lp *LoadPoint) configureChargerType(charger api.Charger) {
// ensure charge meter exists
if lp.chargeMeter == nil {
- if mt, ok := lp.charger.(api.Meter); ok {
+ if mt, ok := charger.(api.Meter); ok {
lp.chargeMeter = mt
} else {
mt := &wrapper.ChargeMeter{}
_ = lp.bus.Subscribe(evChargeCurrent, lp.evChargeCurrentHandler)
- _ = lp.bus.Subscribe(evStopCharge, func() {
+ _ = lp.bus.Subscribe(evChargeStop, func() {
mt.SetPower(0)
})
lp.chargeMeter = mt
@@ -256,49 +144,90 @@ func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, notificationChan chan<- p
}
// ensure charge rater exists
- if rt, ok := lp.charger.(api.ChargeRater); ok {
+ if rt, ok := charger.(api.ChargeRater); ok {
lp.chargeRater = rt
} else {
- rt := wrapper.NewChargeRater(lp.Name, lp.chargeMeter)
+ rt := wrapper.NewChargeRater(lp.log, lp.chargeMeter)
_ = lp.bus.Subscribe(evChargePower, rt.SetChargePower)
- _ = lp.bus.Subscribe(evStartCharge, rt.StartCharge)
- _ = lp.bus.Subscribe(evStopCharge, rt.StopCharge)
+ _ = lp.bus.Subscribe(evChargeStart, rt.StartCharge)
+ _ = lp.bus.Subscribe(evChargeStop, rt.StopCharge)
lp.chargeRater = rt
}
// ensure charge timer exists
- if ct, ok := lp.charger.(api.ChargeTimer); ok {
+ if ct, ok := charger.(api.ChargeTimer); ok {
lp.chargeTimer = ct
} else {
ct := wrapper.NewChargeTimer()
- _ = lp.bus.Subscribe(evStartCharge, ct.StartCharge)
- _ = lp.bus.Subscribe(evStopCharge, ct.StopCharge)
+ _ = lp.bus.Subscribe(evChargeStart, ct.StartCharge)
+ _ = lp.bus.Subscribe(evChargeStop, ct.StopCharge)
lp.chargeTimer = ct
}
+}
+
+// notify sends push messages to clients
+func (lp *LoadPoint) notify(event string) {
+ lp.pushChan <- push.Event{Event: event}
+}
+
+// publish sends values to UI and databases
+func (lp *LoadPoint) publish(key string, val interface{}) {
+ lp.uiChan <- util.Param{Key: key, Val: val}
+}
+
+// evChargeStartHandler sends external start event
+func (lp *LoadPoint) evChargeStartHandler() {
+ lp.notify(evChargeStart)
+}
+
+// evChargeStopHandler sends external stop event
+func (lp *LoadPoint) evChargeStopHandler() {
+ lp.publishChargeProgress()
+ lp.notify(evChargeStop)
+}
+
+// evChargeCurrentHandler updates the dummy charge meter's charge power. This simplifies the main flow
+// where the charge meter can always be treated as present. It assumes that the charge meter cannot consume
+// more than total household consumption. If physical charge meter is present this handler is not used.
+func (lp *LoadPoint) evChargeCurrentHandler(current int64) {
+ power := float64(current*lp.Phases) * Voltage
+
+ if !lp.handler.Enabled() || lp.status != api.StatusC {
+ // 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)
+
+ // expose for UI
+ lp.publish("chargeCurrent", current)
+}
+
+// Name returns the human-readable loadpoint title
+func (lp *LoadPoint) Name() string {
+ return lp.Title
+}
+
+// Prepare loadpoint configuration by adding missing helper elements
+func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Event) {
+ lp.pushChan = pushChan
+ lp.uiChan = uiChan
// event handlers
- _ = lp.bus.Subscribe(evStartCharge, lp.evChargeStartHandler)
- _ = lp.bus.Subscribe(evStopCharge, lp.evChargeStopHandler)
+ _ = lp.bus.Subscribe(evChargeStart, lp.evChargeStartHandler)
+ _ = lp.bus.Subscribe(evChargeStop, lp.evChargeStopHandler)
- // read initial enabled state
- enabled, err := lp.charger.Enabled()
- if err == nil {
- lp.enabled = enabled
- log.INFO.Printf("%s charger %sd", lp.Name, status[lp.enabled])
-
- // prevent immediately disabling charger
- if lp.enabled {
- lp.guardUpdated = lp.clock.Now()
- }
- } else {
- log.ERROR.Printf("%s charger error: %v", lp.Name, err)
- }
-
- // set current to known value
- if err = lp.setTargetCurrent(lp.MinCurrent); err != nil {
- log.ERROR.Println(err)
- }
- lp.bus.Publish(evChargeCurrent, lp.MinCurrent)
+ // prepare charger status
+ lp.handler.Prepare()
}
// connected returns the EVs connection state
@@ -306,54 +235,39 @@ func (lp *LoadPoint) connected() bool {
return lp.status == api.StatusB || lp.status == api.StatusC
}
-// chargingCycle detects charge cycle start and stop events and manages the
-// charge energy counter and charge timer. It guards against duplicate invocation.
-func (lp *LoadPoint) chargingCycle(start bool) {
- if start == lp.charging {
- return
- }
-
- lp.charging = start
-
- if start {
- log.INFO.Printf("%s start charging ->", lp.Name)
- lp.bus.Publish(evStartCharge)
- } else {
- log.INFO.Printf("%s stop charging <-", lp.Name)
- lp.bus.Publish(evStopCharge)
- }
-}
-
// updateChargeStatus updates car status and detects car connected/disconnected events
func (lp *LoadPoint) updateChargeStatus() error {
- status, err := lp.charger.Status()
+ status, err := lp.handler.Status()
if err != nil {
return err
}
- log.DEBUG.Printf("%s charger status: %s", lp.Name, status)
+ lp.log.DEBUG.Printf("charger status: %s", status)
if prevStatus := lp.status; status != prevStatus {
lp.status = status
- // connected
+ // changed from A - connected
if prevStatus == api.StatusA {
- log.INFO.Printf("%s car connected (%s)", lp.Name, string(status))
- if lp.enabled {
- // when car connected don't disable right away
- lp.guardUpdated = lp.clock.Now()
- }
+ lp.log.INFO.Printf("car connected (%s)", string(status))
}
- // disconnected
+ // changed to A - disconnected
if status == api.StatusA {
- log.INFO.Printf("%s car disconnected", lp.Name)
+ lp.log.INFO.Println("car disconnected")
}
- lp.bus.Publish(evChargeCurrent, lp.targetCurrent)
+ // update whenever there is a state change
+ lp.bus.Publish(evChargeCurrent, lp.handler.TargetCurrent())
// start/stop charging cycle
- lp.chargingCycle(status == api.StatusC)
+ if lp.charging = status == api.StatusC; lp.charging {
+ lp.log.INFO.Println("start charging ->")
+ lp.bus.Publish(evChargeStart)
+ } else {
+ lp.log.INFO.Println("stop charging <-")
+ lp.bus.Publish(evChargeStop)
+ }
}
return nil
@@ -361,34 +275,43 @@ func (lp *LoadPoint) updateChargeStatus() error {
func (lp *LoadPoint) maxCurrent(mode api.ChargeMode) int64 {
// grid meter will always be available, if as wrapped pv meter
- targetPower := lp.chargePower - lp.gridPower - lp.batteryPower - lp.ResidualPower
- if lp.batteryMeter == nil {
- log.DEBUG.Printf("%s target power: %.0fW = %.0fW charge - %.0fW grid - %.0fW residual", lp.Name, targetPower, lp.chargePower, lp.gridPower, lp.ResidualPower)
- } else {
- log.DEBUG.Printf("%s target power: %.0fW = %.0fW charge - %.0fW grid - %.0fW battery - %.0fW residual", lp.Name, targetPower, lp.chargePower, lp.gridPower, lp.batteryPower, lp.ResidualPower)
- }
+ targetPower := lp.chargePower - lp.sitePower
+ lp.log.DEBUG.Printf("target power: %.0fW = %.0fW charge - %.0fW available", targetPower, lp.chargePower, lp.sitePower)
// get max charge current
- targetCurrent := clamp(powerToCurrent(targetPower, lp.Voltage, lp.Phases), 0, lp.MaxCurrent)
+ targetCurrent := clamp(powerToCurrent(targetPower, Voltage, lp.Phases), 0, lp.MaxCurrent)
+ // in MinPV mode return at least minCurrent
if mode == api.ModeMinPV && targetCurrent < lp.MinCurrent {
return lp.MinCurrent
}
- if mode == api.ModePV && lp.enabled && targetCurrent < lp.MinCurrent {
- sitePower := lp.gridPower + lp.batteryPower
+ // in PV mode disable if not connected and minCurrent not possible
+ if mode == api.ModePV && lp.status != api.StatusC {
+ lp.pvTimer = time.Time{}
+ if targetCurrent < lp.MinCurrent {
+ return 0
+ }
+
+ return lp.MinCurrent
+ }
+
+ // read only once to simplify testing
+ enabled := lp.handler.Enabled()
+
+ if mode == api.ModePV && enabled && targetCurrent < lp.MinCurrent {
// kick off disable sequence
- if sitePower >= lp.Disable.Threshold {
- log.DEBUG.Printf("%s site power %.0f >= disable threshold %.0f", lp.Name, sitePower, lp.Disable.Threshold)
+ if lp.sitePower >= lp.Disable.Threshold {
+ lp.log.DEBUG.Printf("site power %.0f >= disable threshold %.0f", lp.sitePower, lp.Disable.Threshold)
if lp.pvTimer.IsZero() {
- log.DEBUG.Printf("%s start disable timer", lp.Name)
+ lp.log.DEBUG.Println("start pv disable timer")
lp.pvTimer = lp.clock.Now()
}
if lp.clock.Since(lp.pvTimer) >= lp.Disable.Delay {
- log.DEBUG.Printf("%s disable timer elapsed", lp.Name)
+ lp.log.DEBUG.Println("pv disable timer elapsed")
return 0
}
} else {
@@ -399,21 +322,19 @@ func (lp *LoadPoint) maxCurrent(mode api.ChargeMode) int64 {
return lp.MinCurrent
}
- if mode == api.ModePV && !lp.enabled {
- sitePower := lp.gridPower + lp.batteryPower
-
+ if mode == api.ModePV && !enabled {
// kick off enable sequence
if targetCurrent >= lp.MinCurrent ||
- (lp.Enable.Threshold != 0 && sitePower <= lp.Enable.Threshold) {
- log.DEBUG.Printf("%s site power %.0f < enable threshold %.0f", lp.Name, sitePower, lp.Enable.Threshold)
+ (lp.Enable.Threshold != 0 && lp.sitePower <= lp.Enable.Threshold) {
+ lp.log.DEBUG.Printf("site power %.0f < enable threshold %.0f", lp.sitePower, lp.Enable.Threshold)
if lp.pvTimer.IsZero() {
- log.DEBUG.Printf("%s start enable timer", lp.Name)
+ lp.log.DEBUG.Println("start pv enable timer")
lp.pvTimer = lp.clock.Now()
}
if lp.clock.Since(lp.pvTimer) >= lp.Enable.Delay {
- log.DEBUG.Printf("%s enable timer elapsed", lp.Name)
+ lp.log.DEBUG.Println("pv enable timer elapsed")
return lp.MinCurrent
}
} else {
@@ -424,37 +345,13 @@ func (lp *LoadPoint) maxCurrent(mode api.ChargeMode) int64 {
return 0
}
- log.DEBUG.Printf("%s timer reset", lp.Name)
-
- // reset pv timer
+ // reset timer to disabled state
+ lp.log.DEBUG.Printf("pv timer reset")
lp.pvTimer = time.Time{}
return targetCurrent
}
-// updateModePV handles "minpv" or "pv" modes by setting charger enabled/disabled state
-// and maximum current according to available PV power
-func (lp *LoadPoint) updateModePV(mode api.ChargeMode) error {
- targetCurrent := lp.maxCurrent(mode)
- if !lp.connected() {
- // ensure minimum current when not connected
- // https://github.com/andig/evcc/issues/105
- targetCurrent = min(lp.MinCurrent, targetCurrent)
- }
-
- log.DEBUG.Printf("%s target charge current: %dA", lp.Name, targetCurrent)
-
- if targetCurrent == 0 {
- return lp.rampOff()
- }
-
- if !lp.enabled {
- return lp.rampOn(targetCurrent)
- }
-
- return lp.rampUpDown(targetCurrent)
-}
-
// updateMeter updates and publishes single meter
func (lp *LoadPoint) updateMeter(name string, meter api.Meter, power *float64) error {
value, err := meter.CurrentPower()
@@ -464,7 +361,7 @@ func (lp *LoadPoint) updateMeter(name string, meter api.Meter, power *float64) e
*power = value // update value if no error
- log.DEBUG.Printf("%s %s power: %.1fW", lp.Name, name, *power)
+ lp.log.DEBUG.Printf("%s power: %.1fW", name, *power)
lp.publish(name+"Power", *power)
return nil
@@ -476,113 +373,142 @@ func (lp *LoadPoint) updateMeters() (err error) {
if m != nil {
e := retry.Do(func() error {
return lp.updateMeter(s, m, f)
- }, retry.Attempts(3))
+ }, retryOptions...)
if e != nil {
err = errors.Wrapf(e, "updating %s meter", s)
- log.ERROR.Printf("%s %v", lp.Name, err)
+ lp.log.ERROR.Printf("%v", err)
}
}
}
// read PV meter before charge meter
- retryMeter("grid", lp.gridMeter, &lp.gridPower)
- retryMeter("pv", lp.pvMeter, &lp.pvPower)
- retryMeter("battery", lp.batteryMeter, &lp.batteryPower)
retryMeter("charge", lp.chargeMeter, &lp.chargePower)
return err
}
-// resetGuard sets guardUpdated to an expired value
-func (lp *LoadPoint) resetGuard() {
- lp.guardUpdated = lp.clock.Now().Add(-2 * lp.GuardDuration)
-}
-
-// syncSettings synchronizes charger settings to expected state
-func (lp *LoadPoint) syncSettings() {
- enabled, err := lp.charger.Enabled()
- if err == nil && enabled != lp.enabled {
- log.ERROR.Printf("%s sync enabled", lp.Name)
- err = lp.charger.Enable(lp.enabled)
- }
-
+// chargeDuration returns for how long the charge cycle has been running
+func (lp *LoadPoint) chargeDuration() time.Duration {
+ d, err := lp.chargeTimer.ChargingTime()
if err != nil {
- log.ERROR.Printf("%s charge controller error: %v", lp.Name, err)
+ lp.log.ERROR.Printf("charge timer error: %v", err)
+ return 0
}
+ return d
}
-// update is the main control function. It reevaluates meters and charger state
-func (lp *LoadPoint) update() {
- // read and publish meters first
- meterErr := lp.updateMeters()
+// chargedEnergy returns energy consumption since charge start in kWh
+func (lp *LoadPoint) chargedEnergy() float64 {
+ f, err := lp.chargeRater.ChargedEnergy()
+ if err != nil {
+ lp.log.ERROR.Printf("charge rater error: %v", err)
+ return 0
+ }
+ return f
+}
- // update ChargeRater here to make sure initial meter update is caught
- lp.bus.Publish(evChargeCurrent, lp.targetCurrent)
- lp.bus.Publish(evChargePower, lp.chargePower)
+// publish charged energy and duration
+func (lp *LoadPoint) publishChargeProgress() {
+ lp.publish("chargedEnergy", 1e3*lp.chargedEnergy()) // return Wh for UI
+ lp.publish("chargeDuration", lp.chargeDuration())
+}
- // read and publish status
- if err := retry.Do(lp.updateChargeStatus, retry.Attempts(3)); err != nil {
- log.ERROR.Printf("%s charge controller error: %v", lp.Name, err)
+// remainingChargeDuration returns the remaining charge time
+func (lp *LoadPoint) remainingChargeDuration(chargePercent float64) time.Duration {
+ if !lp.charging {
+ return -1
+ }
+
+ if lp.chargePower > 0 && lp.vehicle != nil {
+ whRemaining := (1 - chargePercent/100.0) * 1e3 * float64(lp.vehicle.Capacity())
+ return time.Duration(float64(time.Hour) * whRemaining / lp.chargePower)
+ }
+
+ return -1
+}
+
+// publish state of charge and remaining charge duration
+func (lp *LoadPoint) publishSoC() {
+ if lp.vehicle == nil {
return
}
- lp.publish("mode", string(lp.GetMode()))
+ if lp.connected() {
+ f, err := lp.vehicle.ChargeState()
+ if err == nil {
+ lp.log.DEBUG.Printf("vehicle soc: %.1f%%", f)
+ lp.publish("socCharge", f)
+ lp.publish("chargeEstimate", lp.remainingChargeDuration(f))
+ return
+ }
+ lp.log.ERROR.Printf("vehicle error: %v", err)
+ }
+
+ lp.publish("socCharge", -1)
+ lp.publish("chargeEstimate", -1)
+}
+
+// Update is the main control function. It reevaluates meters and charger state
+func (lp *LoadPoint) Update(mode api.ChargeMode, sitePower float64) float64 {
+ // read and publish meters first
+ _ = lp.updateMeters()
+
+ lp.sitePower = sitePower
+
+ // update ChargeRater here to make sure initial meter update is caught
+ lp.bus.Publish(evChargeCurrent, lp.handler.TargetCurrent())
+ lp.bus.Publish(evChargePower, lp.chargePower)
+
+ // update progress and soc before status is updated
+ lp.publishChargeProgress()
+ lp.publishSoC()
+
+ // read and publish status
+ if err := retry.Do(lp.updateChargeStatus, retryOptions...); err != nil {
+ lp.log.ERROR.Printf("charge controller error: %v", err)
+ return lp.chargePower
+ }
+
lp.publish("connected", lp.connected())
lp.publish("charging", lp.charging)
// sync settings with charger
if lp.status != api.StatusA {
- lp.syncSettings()
+ lp.handler.SyncSettings()
}
// check if car connected and ready for charging
var err error
// execute loading strategy
- switch mode := lp.GetMode(); mode {
+ switch mode {
case api.ModeOff:
- err = lp.rampOff()
+ err = lp.handler.Ramp(0, true)
+
case api.ModeNow:
// ensure that new connections happen at min current
current := lp.MinCurrent
if lp.connected() {
current = lp.MaxCurrent
}
- err = lp.rampOn(current)
+ err = lp.handler.Ramp(current, true)
+
case api.ModeMinPV, api.ModePV:
- // pv modes require meter measurements
- if meterErr != nil {
- log.WARN.Printf("%s aborting due to meter error", lp.Name)
- break
+ targetCurrent := lp.maxCurrent(mode)
+ if !lp.connected() {
+ // ensure minimum current when not connected
+ // https://github.com/andig/evcc/issues/105
+ targetCurrent = min(lp.MinCurrent, targetCurrent)
}
- err = lp.updateModePV(mode)
+ lp.log.DEBUG.Printf("target charge current: %dA", targetCurrent)
+
+ err = lp.handler.Ramp(targetCurrent)
}
if err != nil {
- log.ERROR.Println(err)
+ lp.log.ERROR.Println(err)
}
- lp.publish("chargedEnergy", 1e3*lp.chargedEnergy()) // return Wh for UI
- lp.publish("chargeDuration", lp.chargeDuration())
-
- lp.publishSoC()
-}
-
-// Run is the loadpoint main control loop. It reacts to trigger events by
-// updating measurements and executing control logic.
-func (lp *LoadPoint) Run(interval time.Duration) {
- ticker := time.NewTicker(interval)
- lp.triggerChan <- struct{}{} // start immediately
-
- for {
- select {
- case <-ticker.C:
- lp.update()
- case <-lp.triggerChan:
- lp.update()
- ticker.Stop()
- ticker = time.NewTicker(interval)
- }
- }
+ return lp.chargePower
}
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index d19be765f..127387e9e 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -1,16 +1,14 @@
package core
import (
- "reflect"
"testing"
"time"
"github.com/andig/evcc/api"
- "github.com/andig/evcc/meter"
"github.com/andig/evcc/mock"
- "github.com/andig/evcc/provider"
"github.com/andig/evcc/push"
"github.com/andig/evcc/util"
+ evbus "github.com/asaskevich/EventBus"
"github.com/benbjohnson/clock"
"github.com/golang/mock/gomock"
)
@@ -20,12 +18,40 @@ const (
lpMaxCurrent int64 = 16
)
-func TestNew(t *testing.T) {
- lp := NewLoadPoint()
+type Null struct{}
+
+func (n *Null) CurrentPower() (float64, error) {
+ return 0, nil
+}
+
+func (n *Null) ChargedEnergy() (float64, error) {
+ return 0, nil
+}
+
+func (n *Null) ChargingTime() (time.Duration, error) {
+ return 0, nil
+}
+
+func attachListeners(lp *LoadPoint) {
+ uiChan := make(chan util.Param)
+ pushChan := make(chan push.Event)
+
+ go func() {
+ for {
+ select {
+ case <-uiChan:
+ case <-pushChan:
+ }
+ }
+ }()
+
+ lp.uiChan = uiChan
+ lp.pushChan = pushChan
+}
+
+func TestNew(t *testing.T) {
+ lp := NewLoadPoint(util.NewLogger("foo"))
- if lp.Mode != api.ModeOff {
- t.Errorf("Mode %v", lp.Mode)
- }
if lp.Phases != 1 {
t.Errorf("Phases %v", lp.Phases)
}
@@ -41,318 +67,129 @@ func TestNew(t *testing.T) {
if lp.status != api.StatusNone {
t.Errorf("status %v", lp.status)
}
- if lp.enabled {
- t.Errorf("enabled %v", lp.enabled)
- }
if lp.charging {
t.Errorf("charging %v", lp.charging)
}
- if lp.targetCurrent != 0 {
- t.Errorf("targetCurrent %v", lp.targetCurrent)
- }
}
-func newLoadPoint(charger api.Charger, pv, gm, cm api.Meter) *LoadPoint {
- lp := NewLoadPoint()
- lp.clock = clock.NewMock()
- lp.clock.(*clock.Mock).Add(time.Hour)
-
- lp.charger = charger
- lp.pvMeter = pv
- lp.gridMeter = gm
-
- // prevent assigning a nil pointer sake of
- // https://groups.google.com/forum/#!topic/golang-nuts/wnH302gBa4I/discussion
- if !(cm == nil || reflect.ValueOf(cm).IsNil()) {
- lp.chargeMeter = cm
- }
-
- uiChan := make(chan util.Param)
- notificationChan := make(chan push.Event)
-
- lp.Prepare(uiChan, notificationChan)
-
- go func() {
- for {
- select {
- case <-uiChan:
- case <-notificationChan:
- }
- }
- }()
-
- return lp
-}
-
-func newEnvironment(t *testing.T, ctrl *gomock.Controller, pm, gm, cm api.Meter) (*LoadPoint, *mock.MockCharger) {
- wb := mock.NewMockCharger(ctrl)
-
- wb.EXPECT().Enabled().Return(true, nil) // initial alignment with wb
- wb.EXPECT().MaxCurrent(lpMinCurrent) // initial alignment with wb
-
- lp := newLoadPoint(wb, pm, gm, cm)
- if !lp.enabled {
- t.Errorf("enabled %v", lp.enabled)
- }
- if lp.guardUpdated != lp.clock.Now() {
- t.Errorf("guardUpdated %v", lp.guardUpdated)
- }
-
- return lp, wb
-}
-
-func TestMeterConfigurations(t *testing.T) {
- tc := []struct {
- gm, cm, pm bool
- }{
- // {false, false, false}, // no meter
- // {false, true, false}, // cm only
- {false, false, true}, // pm only
- {true, false, false}, // gm only
- {true, true, false}, // gm + cm
- {true, false, true}, // gm + pm
- {true, true, true}, // gm + cm + pm
- }
-
- fg := provider.FloatGetter(func() (float64, error) {
- return 1, nil
- })
-
- for _, tc := range tc {
- t.Logf("gm: %+v cm: %v pm: %v", tc.gm, tc.cm, tc.pm)
-
- var gm, pm, cm api.Meter
- if tc.gm {
- gm = meter.NewConfigurable(fg)
- }
- if tc.cm {
- cm = meter.NewConfigurable(fg)
- }
- if tc.pm {
- pm = meter.NewConfigurable(fg)
- }
-
- ctrl := gomock.NewController(t)
- lp, wb := newEnvironment(t, ctrl, pm, gm, cm)
- wb.EXPECT().Status().Return(api.StatusA, nil) // disconnected
- wb.EXPECT().Enable(false) // "off" mode
-
- lp.update()
- }
-}
-
-func TestInitialUpdate(t *testing.T) {
+func TestUpdate(t *testing.T) {
tc := []struct {
status api.ChargeStatus
mode api.ChargeMode
+ expect func(h *mock.MockHandler)
}{
- {status: api.StatusA, mode: api.ModeOff},
- {status: api.StatusA, mode: api.ModeNow},
- {status: api.StatusA, mode: api.ModeMinPV},
- {status: api.StatusA, mode: api.ModePV},
+ {api.StatusA, api.ModeOff, func(h *mock.MockHandler) {
+ h.EXPECT().Ramp(int64(0), true)
+ }},
+ {api.StatusA, api.ModeNow, func(h *mock.MockHandler) {
+ h.EXPECT().Ramp(lpMinCurrent, true)
+ }},
+ {api.StatusA, api.ModeMinPV, func(h *mock.MockHandler) {
+ h.EXPECT().Ramp(lpMinCurrent)
+ }},
+ {api.StatusA, api.ModePV, func(h *mock.MockHandler) {
+ h.EXPECT().Ramp(int64(0)) // zero since update called with 0
+ // h.EXPECT().Enabled().Return(false) // short-circuited due to status != C
+ }},
- {status: api.StatusB, mode: api.ModeOff},
- {status: api.StatusB, mode: api.ModeNow},
- {status: api.StatusB, mode: api.ModeMinPV},
- {status: api.StatusB, mode: api.ModePV},
+ {api.StatusB, api.ModeOff, func(h *mock.MockHandler) {
+ h.EXPECT().Ramp(int64(0), true)
+ }},
+ {api.StatusB, api.ModeNow, func(h *mock.MockHandler) {
+ h.EXPECT().Ramp(lpMaxCurrent, true)
+ }},
+ {api.StatusB, api.ModeMinPV, func(h *mock.MockHandler) {
+ h.EXPECT().Ramp(lpMinCurrent) // min since update called with 0
+ }},
+ {api.StatusB, api.ModePV, func(h *mock.MockHandler) {
+ h.EXPECT().Ramp(int64(0)) // zero since update called with 0
+ // h.EXPECT().Enabled().Return(false) // short-circuited due to status != C
+ }},
- {status: api.StatusC, mode: api.ModeOff},
- {status: api.StatusC, mode: api.ModeNow},
- {status: api.StatusC, mode: api.ModeMinPV},
- {status: api.StatusC, mode: api.ModePV},
+ {api.StatusC, api.ModeOff, func(h *mock.MockHandler) {
+ h.EXPECT().Ramp(int64(0), true)
+ }},
+ {api.StatusC, api.ModeNow, func(h *mock.MockHandler) {
+ h.EXPECT().Ramp(lpMaxCurrent, true)
+ }},
+ {api.StatusC, api.ModeMinPV, func(h *mock.MockHandler) {
+ h.EXPECT().Ramp(lpMinCurrent) // min since update called with 0
+ }},
+ {api.StatusC, api.ModePV, func(h *mock.MockHandler) {
+ h.EXPECT().Ramp(int64(0)) // zero since update called with 0
+ h.EXPECT().Enabled().Return(false)
+ }},
}
for _, tc := range tc {
- t.Logf("%+v\n", tc)
+ t.Log(tc)
+ clck := clock.NewMock()
ctrl := gomock.NewController(t)
+ handler := mock.NewMockHandler(ctrl)
- pm := mock.NewMockMeter(ctrl)
- gm := mock.NewMockMeter(ctrl)
- cm := mock.NewMockMeter(ctrl)
- // cm = nil
-
- lp, wb := newEnvironment(t, ctrl, pm, gm, cm)
- lp.Mode = tc.mode
-
- wb.EXPECT().Status().Return(tc.status, nil)
-
- // values are relevant for PV case
- minPower := float64(lpMinCurrent) * lp.Voltage
- pm.EXPECT().CurrentPower().Return(minPower, nil)
- gm.EXPECT().CurrentPower().Return(float64(0), nil)
- if cm != nil {
- cm.EXPECT().CurrentPower().Return(minPower, nil)
+ lp := &LoadPoint{
+ log: util.NewLogger("foo"),
+ bus: evbus.New(),
+ clock: clck,
+ chargeMeter: &Null{}, //silence nil panics
+ chargeRater: &Null{}, //silence nil panics
+ chargeTimer: &Null{}, //silence nil panics
+ HandlerConfig: HandlerConfig{
+ MinCurrent: lpMinCurrent,
+ MaxCurrent: lpMaxCurrent,
+ },
+ handler: handler,
+ status: tc.status, // no status change
}
- // syncSettings
+ attachListeners(lp)
+
+ handler.EXPECT().Status().Return(tc.status, nil)
+ handler.EXPECT().TargetCurrent().Return(int64(-1))
+
if tc.status != api.StatusA {
- wb.EXPECT().Enabled().Return(true, nil)
+ handler.EXPECT().SyncSettings()
}
- // disable if not connected
- if tc.mode == api.ModeOff {
- wb.EXPECT().Enable(false)
+ if tc.expect != nil {
+ tc.expect(handler)
}
- // power up if now
- if tc.status != api.StatusA && tc.mode == api.ModeNow {
- wb.EXPECT().MaxCurrent(lpMaxCurrent)
- }
-
- lp.update()
-
- // max current if connected & mode now
- if tc.status != api.StatusA && tc.mode == api.ModeNow {
- if lp.targetCurrent != lpMaxCurrent {
- t.Errorf("targetCurrent %v", lp.targetCurrent)
- }
- }
-
- // min current in first cycle
- if tc.mode != api.ModeNow {
- if lp.targetCurrent != lpMinCurrent {
- t.Errorf("targetCurrent %v", lp.targetCurrent)
- }
- }
-
- // status c means charging
- if lp.charging != (tc.status == api.StatusC) {
- t.Errorf("charging %v", lp.charging)
- }
+ lp.Update(tc.mode, 0)
ctrl.Finish()
}
}
-func TestImmediateOnOff(t *testing.T) {
- tc := []struct {
- status api.ChargeStatus
- mode api.ChargeMode
- }{
- {status: api.StatusC, mode: api.ModePV},
- }
+// func TestConsumedPower(t *testing.T) {
+// tc := []struct {
+// grid, pv, battery, consumed float64
+// }{
+// {0, 0, 0, 0}, // silent night
+// {1, 0, 0, 1}, // grid import
+// {0, 1, 0, 1}, // pv sign ignored
+// {0, -1, 0, 1}, // pv sign ignored
+// {1, 1, 0, 2}, // grid import + pv, pv sign ignored
+// {1, -1, 0, 2}, // grid import + pv, pv sign ignored
+// {0, 0, 1, 1}, // battery discharging
+// {0, 0, -1, -1}, // battery charging -> negative result cannot occur in reality
+// {1, -3, 1, 5}, // grid import + pv + battery discharging
+// {1, -3, -1, 3}, // grid import + pv + battery charging -> should not happen in reality
+// {0, -3, -1, 2}, // pv + battery charging
+// {-1, -4, -1, 2}, // grid export + pv + battery charging
+// {-1, -4, 0, 3}, // grid export + pv
+// }
- for _, tc := range tc {
- t.Logf("%+v\n", tc)
+// for _, tc := range tc {
+// res := consumedPower(tc.pv, tc.battery, tc.grid)
+// if res != tc.consumed {
+// t.Errorf("consumedPower wanted %.f, got %.f", tc.consumed, res)
+// }
+// }
+// }
- ctrl := gomock.NewController(t)
-
- pm := mock.NewMockMeter(ctrl)
- gm := mock.NewMockMeter(ctrl)
- cm := mock.NewMockMeter(ctrl)
- // cm = nil
-
- lp, wb := newEnvironment(t, ctrl, pm, gm, cm)
- lp.Mode = tc.mode
-
- // -- round 1
- wb.EXPECT().Status().Return(tc.status, nil)
-
- // values are relevant for PV case
- minPower := float64(lpMinCurrent) * lp.Voltage * float64(lp.Phases)
- pm.EXPECT().CurrentPower().Return(minPower, nil)
- gm.EXPECT().CurrentPower().Return(0.0, nil)
- if cm != nil {
- cm.EXPECT().CurrentPower().Return(minPower, nil)
- }
-
- // disable if not connected
- if tc.status != api.StatusA && tc.mode == api.ModeOff {
- wb.EXPECT().Enable(false)
- }
-
- // power up if now
- if tc.status != api.StatusA && tc.mode == api.ModeNow {
- wb.EXPECT().MaxCurrent(lpMaxCurrent)
- }
-
- wb.EXPECT().Enabled().Return(true, nil) // syncSettings
- lp.update()
-
- // max current if connected & mode now
- if tc.status != api.StatusA && tc.mode == api.ModeNow {
- if lp.targetCurrent != lpMaxCurrent {
- t.Errorf("targetCurrent %v", lp.targetCurrent)
- }
- }
-
- // min current in first cycle
- if tc.mode != api.ModeNow {
- if lp.targetCurrent != lpMinCurrent {
- t.Errorf("targetCurrent %v", lp.targetCurrent)
- }
- }
-
- // status c means charging
- if lp.charging != (tc.status == api.StatusC) {
- t.Errorf("charging %v", lp.charging)
- }
-
- // -- round 2
- wb.EXPECT().Status().Return(tc.status, nil)
-
- pm.EXPECT().CurrentPower().Return(minPower, nil)
- gm.EXPECT().CurrentPower().Return(-2*minPower, nil)
- if cm != nil {
- cm.EXPECT().CurrentPower().Return(1.0, nil)
- }
-
- wb.EXPECT().MaxCurrent(2 * lpMinCurrent)
-
- wb.EXPECT().Enabled().Return(true, nil) // syncSettings
- lp.update()
-
- // -- round 3
- t.Logf("%+v - 3 (status: %v, enabled: %v, current %d)\n", tc, lp.status, lp.enabled, lp.targetCurrent)
-
- wb.EXPECT().Status().Return(tc.status, nil)
-
- pm.EXPECT().CurrentPower().Return(minPower, nil)
- gm.EXPECT().CurrentPower().Return(-2*minPower, nil)
- if cm != nil {
- cm.EXPECT().CurrentPower().Return(1.0, nil)
- }
-
- wb.EXPECT().MaxCurrent(lpMinCurrent)
-
- lp.SetMode(api.ModeOff)
-
- wb.EXPECT().Enabled().Return(true, nil) // syncSettings
- lp.update()
-
- ctrl.Finish()
- }
-}
-
-func TestConsumedPower(t *testing.T) {
- tc := []struct {
- grid, pv, battery, consumed float64
- }{
- {0, 0, 0, 0}, // silent night
- {1, 0, 0, 1}, // grid import
- {0, 1, 0, 1}, // pv sign ignored
- {0, -1, 0, 1}, // pv sign ignored
- {1, 1, 0, 2}, // grid import + pv, pv sign ignored
- {1, -1, 0, 2}, // grid import + pv, pv sign ignored
- {0, 0, 1, 1}, // battery discharging
- {0, 0, -1, -1}, // battery charging -> negative result cannot occur in reality
- {1, -3, 1, 5}, // grid import + pv + battery discharging
- {1, -3, -1, 3}, // grid import + pv + battery charging -> should not happen in reality
- {0, -3, -1, 2}, // pv + battery charging
- {-1, -4, -1, 2}, // grid export + pv + battery charging
- {-1, -4, 0, 3}, // grid export + pv
- }
-
- for _, tc := range tc {
- res := consumedPower(tc.pv, tc.battery, tc.grid)
- if res != tc.consumed {
- t.Errorf("consumedPower wanted %.f, got %.f", tc.consumed, res)
- }
- }
-}
-
-func TestPVHysteresis(t *testing.T) {
+func TestPVHysteresisForStatusC(t *testing.T) {
dt := time.Minute
type se struct {
site float64
@@ -459,38 +296,79 @@ func TestPVHysteresis(t *testing.T) {
t.Log(tc)
clck := clock.NewMock()
- lp := LoadPoint{
+ ctrl := gomock.NewController(t)
+ handler := mock.NewMockHandler(ctrl)
+
+ Voltage = 100
+ lp := &LoadPoint{
+ log: util.NewLogger("foo"),
clock: clck,
- ChargerHandler: ChargerHandler{
+ HandlerConfig: HandlerConfig{
MinCurrent: lpMinCurrent,
MaxCurrent: lpMaxCurrent,
- enabled: tc.enabled,
},
- Config: Config{
- Voltage: 100,
- Phases: 10,
- Enable: ThresholdConfig{
- Threshold: tc.enable,
- Delay: dt,
- },
- Disable: ThresholdConfig{
- Threshold: tc.disable,
- Delay: dt,
- },
+ handler: handler,
+ Phases: 10,
+ Enable: ThresholdConfig{
+ Threshold: tc.enable,
+ Delay: dt,
+ },
+ Disable: ThresholdConfig{
+ Threshold: tc.disable,
+ Delay: dt,
},
- gridPower: 0,
}
+ // charging, otherwise PV mode logic is short-circuited
+ lp.status = api.StatusC
+
start := clck.Now()
for step, se := range tc.series {
clck.Set(start.Add(se.delay))
- lp.gridPower = se.site
+ lp.sitePower = se.site
+
+ // maxCurrent will read enabled state in PV mode
+ handler.EXPECT().Enabled().Return(tc.enabled)
current := lp.maxCurrent(api.ModePV)
if current != se.current {
t.Errorf("step %d: wanted %d, got %d", step, se.current, current)
}
}
+
+ ctrl.Finish()
}
}
+
+func TestPVHysteresisForStatusOtherThanC(t *testing.T) {
+
+ clck := clock.NewMock()
+ ctrl := gomock.NewController(t)
+ handler := mock.NewMockHandler(ctrl)
+
+ Voltage = 100
+ lp := &LoadPoint{
+ log: util.NewLogger("foo"),
+ clock: clck,
+ HandlerConfig: HandlerConfig{
+ MinCurrent: lpMinCurrent,
+ MaxCurrent: lpMaxCurrent,
+ },
+ handler: handler,
+ Phases: 10,
+ }
+
+ // not connected, test PV mode logic short-circuited
+ lp.status = api.StatusA
+
+ // maxCurrent will read enabled state in PV mode
+ lp.sitePower = -float64(minA*lp.Phases)*Voltage + 1 // 1W below min power
+ current := lp.maxCurrent(api.ModePV)
+
+ if current != 0 {
+ t.Errorf("PV mode could not disable charger as expected. Expected 0, got %d", current)
+ }
+
+ ctrl.Finish()
+}
diff --git a/core/log.go b/core/log.go
deleted file mode 100644
index a38ced6d3..000000000
--- a/core/log.go
+++ /dev/null
@@ -1,5 +0,0 @@
-package core
-
-import "github.com/andig/evcc/util"
-
-var log = util.NewLogger("load")
diff --git a/core/site.go b/core/site.go
new file mode 100644
index 000000000..636c40ccd
--- /dev/null
+++ b/core/site.go
@@ -0,0 +1,369 @@
+package core
+
+import (
+ "sync"
+ "time"
+
+ "github.com/andig/evcc/api"
+ "github.com/andig/evcc/core/wrapper"
+ "github.com/andig/evcc/push"
+ "github.com/andig/evcc/util"
+ "github.com/avast/retry-go"
+ "github.com/pkg/errors"
+)
+
+//go:generate mockgen -package mock -destination ../mock/mock_loadpoint.go github.com/andig/evcc/core LoadPointer
+
+// LoadPointer abstracts the LoadPoint implementation for testing
+type LoadPointer interface {
+ Update(api.ChargeMode, float64) float64
+}
+
+// Site is the main configuration container. A site can host multiple loadpoints.
+type Site struct {
+ sync.Mutex // guard status
+ triggerChan chan struct{} // API updates
+ uiChan chan<- util.Param // client push messages
+ log *util.Logger
+
+ // configuration
+ Title string `mapstructure:"title"` // UI title
+ Voltage float64 `mapstructure:"voltage"` // Operating voltage. 230V for Germany.
+ ResidualPower float64 `mapstructure:"residualPower"` // PV meter only: household usage. Grid meter: household safety margin
+ Mode api.ChargeMode `mapstructure:"mode"` // Charge mode, guarded by mutex
+ Meters MetersConfig // Meter references
+
+ // meters
+ gridMeter api.Meter // Grid usage meter
+ pvMeter api.Meter // PV generation meter
+ batteryMeter api.Meter // Battery charging meter
+
+ loadpoints []LoadPointer // Loadpoints
+
+ // cached state
+ gridPower float64 // Grid power
+ pvPower float64 // PV power
+ batteryPower float64 // Battery charge power
+}
+
+// MetersConfig contains the loadpoint's meter configuration
+type MetersConfig struct {
+ GridMeterRef string `mapstructure:"grid"` // Grid usage meter reference
+ PVMeterRef string `mapstructure:"pv"` // PV generation meter reference
+ BatteryMeterRef string `mapstructure:"battery"` // Battery charging meter reference
+}
+
+// NewSiteFromConfig creates a new site
+func NewSiteFromConfig(
+ log *util.Logger,
+ cp configProvider,
+ other map[string]interface{},
+ loadpoints []*LoadPoint,
+) *Site {
+ site := NewSite()
+ util.DecodeOther(log, other, &site)
+
+ // workaround mapstructure
+ if site.Mode == "0" {
+ site.Mode = api.ModeOff
+ }
+
+ // configure meter from references
+ // if site.Meters.PVMeterRef == "" && site.Meters.GridMeterRef == "" {
+ // site.log.FATAL.Fatal("config: missing either pv or grid meter")
+ // }
+ if site.Meters.GridMeterRef == "" {
+ site.log.FATAL.Fatal("config: missing grid meter")
+ }
+ if site.Meters.GridMeterRef != "" {
+ site.gridMeter = cp.Meter(site.Meters.GridMeterRef)
+ }
+ if site.Meters.PVMeterRef != "" {
+ site.pvMeter = cp.Meter(site.Meters.PVMeterRef)
+ }
+ if site.Meters.BatteryMeterRef != "" {
+ site.batteryMeter = cp.Meter(site.Meters.BatteryMeterRef)
+ }
+
+ // convert loadpoints to interfaces
+ for _, lp := range loadpoints {
+ site.loadpoints = append(site.loadpoints, lp)
+ }
+
+ Voltage = site.Voltage
+
+ return site
+}
+
+// NewSite creates a Site with sane defaults
+func NewSite() *Site {
+ lp := &Site{
+ log: util.NewLogger("core"),
+ triggerChan: make(chan struct{}, 1),
+ Mode: api.ModeOff,
+ Voltage: 230, // V
+ }
+
+ return lp
+}
+
+// SiteConfiguration contains the global site configuration
+type SiteConfiguration struct {
+ Title string `json:"title"`
+ Mode string `json:"mode"`
+ GridMeter bool `json:"gridMeter"`
+ PVMeter bool `json:"pvMeter"`
+ BatteryMeter bool `json:"batteryMeter"`
+ LoadPoints []LoadpointConfiguration `json:"loadpoints"`
+}
+
+// LoadpointConfiguration is the loadpoint feature structure
+type LoadpointConfiguration struct {
+ Title string `json:"title"`
+ Phases int64 `json:"phases"`
+ MinCurrent int64 `json:"minCurrent"`
+ MaxCurrent int64 `json:"maxCurrent"`
+ ChargeMeter bool `json:"chargeMeter"`
+ SoC bool `json:"soc"`
+ SoCCapacity int64 `json:"socCapacity"`
+ SoCTitle string `json:"socTitle"`
+}
+
+func (lp *LoadPoint) hasChargeMeter() bool {
+ _, isWrapped := lp.chargeMeter.(*wrapper.ChargeMeter)
+ return lp.chargeMeter != nil && !isWrapped
+}
+
+// LoadPoints returns the array of associated loadpoints
+func (site *Site) LoadPoints() []*LoadPoint {
+ res := make([]*LoadPoint, 0, len(site.loadpoints))
+ for _, lp := range site.loadpoints {
+ res = append(res, lp.(*LoadPoint))
+ }
+ return res
+}
+
+// Configuration returns meter configuration
+func (site *Site) Configuration() SiteConfiguration {
+ c := SiteConfiguration{
+ Title: site.Title,
+ Mode: string(site.GetMode()),
+ GridMeter: site.gridMeter != nil,
+ PVMeter: site.pvMeter != nil,
+ BatteryMeter: site.batteryMeter != nil,
+ }
+
+ for _, lptr := range site.loadpoints {
+ lp := lptr.(*LoadPoint)
+
+ lpc := LoadpointConfiguration{
+ Title: lp.Name(),
+ Phases: lp.Phases,
+ MinCurrent: lp.MinCurrent,
+ MaxCurrent: lp.MaxCurrent,
+ ChargeMeter: lp.hasChargeMeter(),
+ }
+
+ if lp.vehicle != nil {
+ lpc.SoC = true
+ lpc.SoCCapacity = lp.vehicle.Capacity()
+ lpc.SoCTitle = lp.vehicle.Title()
+ }
+
+ c.LoadPoints = append(c.LoadPoints, lpc)
+ }
+
+ return c
+}
+
+// DumpConfig site configuration
+func (site *Site) DumpConfig() {
+ site.log.INFO.Println("site config:")
+ site.log.INFO.Printf(" mode: %s", site.GetMode())
+ site.log.INFO.Printf(" grid %s", presence[site.gridMeter != nil])
+ site.log.INFO.Printf(" pv %s", presence[site.pvMeter != nil])
+ site.log.INFO.Printf(" battery %s", presence[site.batteryMeter != nil])
+
+ for i, lptr := range site.loadpoints {
+ lp := lptr.(*LoadPoint)
+ lp.log.INFO.Printf("loadpoint %d config:", i)
+
+ lp.log.INFO.Printf(" vehicle %s", presence[lp.vehicle != nil])
+ lp.log.INFO.Printf(" charge %s", presence[lp.hasChargeMeter()])
+
+ charger := lp.handler.(*ChargerHandler).charger
+ _, power := charger.(api.Meter)
+ _, energy := charger.(api.ChargeRater)
+ _, timer := charger.(api.ChargeTimer)
+
+ lp.log.INFO.Println(" charger config:")
+ lp.log.INFO.Printf(" power %s", presence[power])
+ lp.log.INFO.Printf(" energy %s", presence[energy])
+ lp.log.INFO.Printf(" timer %s", presence[timer])
+ }
+}
+
+// Update triggers loadpoint to run main control loop and push messages to UI socket
+func (site *Site) Update() {
+ select {
+ case site.triggerChan <- struct{}{}: // non-blocking send
+ default:
+ site.log.WARN.Printf("update blocked")
+ }
+}
+
+// GetMode returns loadpoint charge mode
+func (site *Site) GetMode() api.ChargeMode {
+ site.Lock()
+ defer site.Unlock()
+ return site.Mode
+}
+
+// SetMode sets loadpoint charge mode
+func (site *Site) SetMode(mode api.ChargeMode) {
+ site.Lock()
+ defer site.Unlock()
+
+ site.log.INFO.Printf("set charge mode: %s", string(mode))
+
+ // apply immediately
+ if site.Mode != mode {
+ site.Mode = mode
+ site.Update()
+ }
+}
+
+// publish sends values to UI and databases
+func (site *Site) publish(key string, val interface{}) {
+ // test helper
+ if site.uiChan == nil {
+ return
+ }
+
+ site.uiChan <- util.Param{
+ Key: key,
+ Val: val,
+ }
+}
+
+// updateMeter updates and publishes single meter
+func (site *Site) updateMeter(name string, meter api.Meter, power *float64) error {
+ value, err := meter.CurrentPower()
+ if err != nil {
+ return err
+ }
+
+ *power = value // update value if no error
+
+ site.log.DEBUG.Printf("%s power: %.1fW", name, *power)
+ site.publish(name+"Power", *power)
+
+ return nil
+}
+
+// updateMeter updates and publishes single meter
+func (site *Site) updateMeters() (err error) {
+ retryMeter := func(s string, m api.Meter, f *float64) {
+ if m != nil {
+ e := retry.Do(func() error {
+ return site.updateMeter(s, m, f)
+ }, retryOptions...)
+
+ if e != nil {
+ err = errors.Wrapf(e, "updating %s meter", s)
+ site.log.ERROR.Println(err)
+ }
+ }
+ }
+
+ // read PV meter before charge meter
+ retryMeter("grid", site.gridMeter, &site.gridPower)
+ retryMeter("pv", site.pvMeter, &site.pvPower)
+ retryMeter("battery", site.batteryMeter, &site.batteryPower)
+
+ return err
+}
+
+// consumedPower estimates how much power the charger might have consumed given it was the only load
+// func (site *Site) consumedPower() float64 {
+// return consumedPower(lp.pvPower, lp.batteryPower, lp.gridPower)
+// }
+
+// sitePower returns the net power exported by the site minus a residual margin.
+// negative values mean grid: export, battery: charging
+func (site *Site) sitePower() (float64, error) {
+ if err := site.updateMeters(); err != nil {
+ return 0, err
+ }
+
+ sitePower := sitePower(site.gridPower, site.batteryPower, site.ResidualPower)
+ site.log.DEBUG.Printf("site power: %.0fW", sitePower)
+
+ return sitePower, nil
+}
+
+func (site *Site) update() error {
+ mode := site.GetMode()
+ site.publish("mode", string(mode))
+
+ sitePower, err := site.sitePower()
+ if err != nil {
+ return err
+ }
+
+ for idx, lp := range site.loadpoints {
+ usedPower := lp.Update(mode, sitePower)
+ remainingPower := sitePower + usedPower
+ site.log.DEBUG.Printf("lp-%d remaining power: %.0fW = %.0fW - %.0fW", idx+1, remainingPower, sitePower, usedPower)
+ sitePower = remainingPower
+ }
+
+ return nil
+}
+
+// Prepare attaches communication channels to site and loadpoints
+func (site *Site) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Event) {
+ site.uiChan = uiChan
+
+ for id, loadPoint := range site.loadpoints {
+ lpUIChan := make(chan util.Param)
+ lpPushChan := make(chan push.Event)
+
+ // pipe messages through go func to add id
+ go func(id int) {
+ for {
+ select {
+ case param := <-lpUIChan:
+ param.LoadPoint = &id
+ uiChan <- param
+ case ev := <-lpPushChan:
+ ev.LoadPoint = &id
+ pushChan <- ev
+ }
+ }
+ }(id)
+
+ loadPoint.(*LoadPoint).Prepare(lpUIChan, lpPushChan)
+ }
+}
+
+// Run is the main control loop. It reacts to trigger events by
+// updating measurements and executing control logic.
+func (site *Site) Run(interval time.Duration) {
+ // update ticker
+ ticker := time.NewTicker(interval)
+ site.triggerChan <- struct{}{} // start immediately
+
+ for {
+ select {
+ case <-ticker.C:
+ if site.update() != nil {
+ site.triggerChan <- struct{}{} // restart immediately
+ }
+ case <-site.triggerChan:
+ _ = site.update()
+ ticker.Stop()
+ ticker = time.NewTicker(interval)
+ }
+ }
+}
diff --git a/core/site_test.go b/core/site_test.go
new file mode 100644
index 000000000..b83893468
--- /dev/null
+++ b/core/site_test.go
@@ -0,0 +1,60 @@
+package core
+
+import (
+ "testing"
+
+ "github.com/andig/evcc/api"
+ "github.com/andig/evcc/mock"
+ "github.com/andig/evcc/util"
+ "github.com/golang/mock/gomock"
+)
+
+func TestSitePower(t *testing.T) {
+ tc := []struct {
+ grid, battery, site float64
+ }{
+ {0, 0, 0}, // silent night
+ {0, 1, 1}, // battery discharging
+ {0, -1, -1}, // battery charging -> negative result cannot occur in reality
+ {1, 0, 1}, // grid import
+ {1, 1, 2}, // grid import + battery discharging
+ {-1, 0, -1}, // grid export
+ {-1, -1, -2}, // grid export + battery charging
+ }
+
+ for _, tc := range tc {
+ res := sitePower(tc.grid, tc.battery, 0)
+ if res != tc.site {
+ t.Errorf("sitePower wanted %.f, got %.f", tc.site, res)
+ }
+ }
+}
+
+func TestSiteUpdateMultipleLoadpoints(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ sitePower := -1000.0
+ lps := []float64{300.0, 400.0, 500.0}
+
+ gm := mock.NewMockMeter(ctrl)
+ gm.EXPECT().CurrentPower().Return(sitePower, nil)
+
+ site := &Site{
+ log: util.NewLogger("foo"),
+ Mode: api.ModePV,
+ gridMeter: gm,
+ }
+
+ availablePower := sitePower
+
+ for _, usedPower := range lps {
+ lp := mock.NewMockLoadPointer(ctrl)
+ site.loadpoints = append(site.loadpoints, lp)
+
+ lp.EXPECT().Update(api.ModePV, availablePower).Return(usedPower)
+ availablePower += usedPower
+ }
+
+ _ = site.update()
+}
diff --git a/core/wrapper/chargerater.go b/core/wrapper/chargerater.go
index 6084eef40..8c831c45c 100644
--- a/core/wrapper/chargerater.go
+++ b/core/wrapper/chargerater.go
@@ -6,6 +6,7 @@ import (
"time"
"github.com/andig/evcc/api"
+ "github.com/andig/evcc/util"
"github.com/benbjohnson/clock"
)
@@ -14,8 +15,8 @@ import (
// keeps track of consumed energy by regularly updating consumed power.
type ChargeRater struct {
sync.Mutex
+ log *util.Logger
clck clock.Clock
- name string
meter api.Meter
charging bool
start time.Time
@@ -24,10 +25,10 @@ type ChargeRater struct {
}
// NewChargeRater creates charge rater and initializes realtime clock
-func NewChargeRater(name string, meter api.Meter) *ChargeRater {
+func NewChargeRater(log *util.Logger, meter api.Meter) *ChargeRater {
return &ChargeRater{
+ log: log,
clck: clock.New(),
- name: name,
meter: meter,
}
}
@@ -46,9 +47,9 @@ func (cr *ChargeRater) StartCharge() {
if m, ok := cr.meter.(api.MeterEnergy); ok {
if f, err := m.TotalEnergy(); err == nil {
cr.startEnergy = f
- log.DEBUG.Printf("%s charge start energy: %.0fkWh", cr.name, f)
+ cr.log.DEBUG.Printf("charge start energy: %.0fkWh", f)
} else {
- log.ERROR.Printf("%s charge meter error %v", cr.name, err)
+ cr.log.ERROR.Printf("charge meter error %v", err)
}
} else {
cr.chargedEnergy = 0
@@ -67,9 +68,9 @@ func (cr *ChargeRater) StopCharge() {
if m, ok := cr.meter.(api.MeterEnergy); ok {
if f, err := m.TotalEnergy(); err == nil {
cr.chargedEnergy = f - cr.startEnergy
- log.DEBUG.Printf("%s final charge energy: %.0fkWh", cr.name, cr.chargedEnergy)
+ cr.log.DEBUG.Printf("final charge energy: %.0fkWh", cr.chargedEnergy)
} else {
- log.ERROR.Printf("%s charge meter error %v", cr.name, err)
+ cr.log.ERROR.Printf("charge meter error %v", err)
}
}
}
@@ -111,7 +112,7 @@ func (cr *ChargeRater) ChargedEnergy() (float64, error) {
return f - cr.startEnergy, nil
}
- return 0, fmt.Errorf("%s charge meter error %v", cr.name, err)
+ return 0, fmt.Errorf("charge meter error %v", err)
}
// return charged energy sofar if meter is not used
diff --git a/core/wrapper/chargerater_test.go b/core/wrapper/chargerater_test.go
index fb0b3f477..e55ba0e05 100644
--- a/core/wrapper/chargerater_test.go
+++ b/core/wrapper/chargerater_test.go
@@ -6,12 +6,13 @@ import (
"github.com/andig/evcc/meter"
"github.com/andig/evcc/mock"
+ "github.com/andig/evcc/util"
"github.com/benbjohnson/clock"
"github.com/golang/mock/gomock"
)
func TestNoMeter(t *testing.T) {
- cr := NewChargeRater("foo", nil)
+ cr := NewChargeRater(util.NewLogger("foo"), nil)
clck := clock.NewMock()
cr.clck = clck
@@ -53,7 +54,7 @@ func TestWrappedMeter(t *testing.T) {
TotalEnergy().
Return(3.0, nil)
- cr := NewChargeRater("foo", cm)
+ cr := NewChargeRater(util.NewLogger("foo"), cm)
clck := clock.NewMock()
cr.clck = clck
diff --git a/core/wrapper/log.go b/core/wrapper/log.go
deleted file mode 100644
index 6e9017896..000000000
--- a/core/wrapper/log.go
+++ /dev/null
@@ -1,5 +0,0 @@
-package wrapper
-
-import "github.com/andig/evcc/util"
-
-var log = util.NewLogger("wrap")
diff --git a/evcc.dist.yaml b/evcc.dist.yaml
index 648967547..c2ae45c88 100644
--- a/evcc.dist.yaml
+++ b/evcc.dist.yaml
@@ -27,10 +27,10 @@ messaging:
events:
start: # charge start event
title: Charge started
- msg: ${loadpoint} started charging in "${mode}" mode
+ msg: Started charging in "${mode}" mode
stop: # charge stop event
title: Charge finished
- msg: ${loadpoint} finished charging. Charged ${energy:%.1f}kWh in ${duration}.
+ msg: Finished charging ${energy:%.1fk}kWh in ${duration}.
services:
- type: pushover
app: # app id
@@ -121,7 +121,7 @@ chargers:
- name: go-e
type: go-e # go-eCharger
uri: http://192.168.1.4 # either go-e local address
- token: 4711c # or go-e cloud API token
+ # token: 4711c # or go-e cloud API token
cache: 10s # go-e cloud API cache duration
- name: keba
type: keba # KEBA charger
@@ -209,14 +209,20 @@ vehicles:
# vin: WP...
# cache: 5m
-loadpoints:
-- name: main # name for logging
- vehicle: audi
- charger: wallbe # charger
+site:
+ title: Home
meters:
grid: grid # grid meter
pv: pv # pv meter
battery: battery # battery meter
+
+loadpoints:
+- title: Ladepunkt # ui display name
+ vehicle: audi
+ charger: wallbe # charger
+ phases: 3 # ev phases (default 3)
+ sensitivity: 1 # current raise/lower step size (default 10A)
+ meters:
charge: charge # charge meter
enable: # pv mode enable behavior
delay: 1m # threshold must be exceeded for this long
@@ -227,5 +233,3 @@ loadpoints:
guardduration: 10m # switch charger contactor not more often than this (default 10m)
mincurrent: 6 # minimum charge current (default 6A)
maxcurrent: 16 # maximum charge current (default 16A)
- phases: 3 # ev phases (default 3)
- sensitivity: 1 # current raise/lower step size (default 10A)
diff --git a/meter/meter.go b/meter/meter.go
index 6c833fe58..231ea7c79 100644
--- a/meter/meter.go
+++ b/meter/meter.go
@@ -34,7 +34,7 @@ func NewConfigurableFromConfig(log *util.Logger, other map[string]interface{}) a
}
// NewConfigurable creates a new charger
-func NewConfigurable(currentPowerG provider.FloatGetter) api.Meter {
+func NewConfigurable(currentPowerG func() (float64, error)) api.Meter {
return &Meter{
currentPowerG: currentPowerG,
}
@@ -42,7 +42,7 @@ func NewConfigurable(currentPowerG provider.FloatGetter) api.Meter {
// Meter is an api.Meter implementation with configurable getters and setters.
type Meter struct {
- currentPowerG provider.FloatGetter
+ currentPowerG func() (float64, error)
}
// CurrentPower implements the Meter.CurrentPower interface
@@ -52,11 +52,11 @@ func (m *Meter) CurrentPower() (float64, error) {
// MeterEnergy is an api.MeterEnergy implementation with configurable getters and setters.
type MeterEnergy struct {
- totalEnergyG provider.FloatGetter
+ totalEnergyG func() (float64, error)
}
// NewMeterEnergy creates a new charger
-func NewMeterEnergy(totalEnergyG provider.FloatGetter) api.MeterEnergy {
+func NewMeterEnergy(totalEnergyG func() (float64, error)) api.MeterEnergy {
return &MeterEnergy{
totalEnergyG: totalEnergyG,
}
diff --git a/meter/modbus.go b/meter/modbus.go
index b6d961a3d..0e7a43767 100644
--- a/meter/modbus.go
+++ b/meter/modbus.go
@@ -81,7 +81,7 @@ func NewModbusFromConfig(log *util.Logger, other map[string]interface{}) api.Met
return m
}
-// floatGetter executes configured modbus read operation and implements provider.FloatGetter
+// floatGetter executes configured modbus read operation and implements func() (float64, error)
func (m *Modbus) floatGetter(op modbus.Operation) (float64, error) {
m.conn.Slave(m.slaveID)
diff --git a/mock/mock.go b/mock/mock_api.go
similarity index 100%
rename from mock/mock.go
rename to mock/mock_api.go
diff --git a/mock/mock_chargerhandler.go b/mock/mock_chargerhandler.go
new file mode 100644
index 000000000..42e1060cc
--- /dev/null
+++ b/mock/mock_chargerhandler.go
@@ -0,0 +1,120 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/andig/evcc/core (interfaces: Handler)
+
+// Package mock is a generated GoMock package.
+package mock
+
+import (
+ api "github.com/andig/evcc/api"
+ gomock "github.com/golang/mock/gomock"
+ reflect "reflect"
+)
+
+// MockHandler is a mock of Handler interface
+type MockHandler struct {
+ ctrl *gomock.Controller
+ recorder *MockHandlerMockRecorder
+}
+
+// MockHandlerMockRecorder is the mock recorder for MockHandler
+type MockHandlerMockRecorder struct {
+ mock *MockHandler
+}
+
+// NewMockHandler creates a new mock instance
+func NewMockHandler(ctrl *gomock.Controller) *MockHandler {
+ mock := &MockHandler{ctrl: ctrl}
+ mock.recorder = &MockHandlerMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use
+func (m *MockHandler) EXPECT() *MockHandlerMockRecorder {
+ return m.recorder
+}
+
+// Enabled mocks base method
+func (m *MockHandler) Enabled() bool {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Enabled")
+ ret0, _ := ret[0].(bool)
+ return ret0
+}
+
+// Enabled indicates an expected call of Enabled
+func (mr *MockHandlerMockRecorder) Enabled() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Enabled", reflect.TypeOf((*MockHandler)(nil).Enabled))
+}
+
+// Prepare mocks base method
+func (m *MockHandler) Prepare() {
+ m.ctrl.T.Helper()
+ m.ctrl.Call(m, "Prepare")
+}
+
+// Prepare indicates an expected call of Prepare
+func (mr *MockHandlerMockRecorder) Prepare() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Prepare", reflect.TypeOf((*MockHandler)(nil).Prepare))
+}
+
+// Ramp mocks base method
+func (m *MockHandler) Ramp(arg0 int64, arg1 ...bool) error {
+ m.ctrl.T.Helper()
+ varargs := []interface{}{arg0}
+ for _, a := range arg1 {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "Ramp", varargs...)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Ramp indicates an expected call of Ramp
+func (mr *MockHandlerMockRecorder) Ramp(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]interface{}{arg0}, arg1...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ramp", reflect.TypeOf((*MockHandler)(nil).Ramp), varargs...)
+}
+
+// Status mocks base method
+func (m *MockHandler) Status() (api.ChargeStatus, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Status")
+ ret0, _ := ret[0].(api.ChargeStatus)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Status indicates an expected call of Status
+func (mr *MockHandlerMockRecorder) Status() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockHandler)(nil).Status))
+}
+
+// SyncSettings mocks base method
+func (m *MockHandler) SyncSettings() {
+ m.ctrl.T.Helper()
+ m.ctrl.Call(m, "SyncSettings")
+}
+
+// SyncSettings indicates an expected call of SyncSettings
+func (mr *MockHandlerMockRecorder) SyncSettings() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncSettings", reflect.TypeOf((*MockHandler)(nil).SyncSettings))
+}
+
+// TargetCurrent mocks base method
+func (m *MockHandler) TargetCurrent() int64 {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "TargetCurrent")
+ ret0, _ := ret[0].(int64)
+ return ret0
+}
+
+// TargetCurrent indicates an expected call of TargetCurrent
+func (mr *MockHandlerMockRecorder) TargetCurrent() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TargetCurrent", reflect.TypeOf((*MockHandler)(nil).TargetCurrent))
+}
diff --git a/mock/mock_loadpoint.go b/mock/mock_loadpoint.go
new file mode 100644
index 000000000..75e60af71
--- /dev/null
+++ b/mock/mock_loadpoint.go
@@ -0,0 +1,48 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/andig/evcc/core (interfaces: LoadPointer)
+
+// Package mock is a generated GoMock package.
+package mock
+
+import (
+ api "github.com/andig/evcc/api"
+ gomock "github.com/golang/mock/gomock"
+ reflect "reflect"
+)
+
+// MockLoadPointer is a mock of LoadPointer interface
+type MockLoadPointer struct {
+ ctrl *gomock.Controller
+ recorder *MockLoadPointerMockRecorder
+}
+
+// MockLoadPointerMockRecorder is the mock recorder for MockLoadPointer
+type MockLoadPointerMockRecorder struct {
+ mock *MockLoadPointer
+}
+
+// NewMockLoadPointer creates a new mock instance
+func NewMockLoadPointer(ctrl *gomock.Controller) *MockLoadPointer {
+ mock := &MockLoadPointer{ctrl: ctrl}
+ mock.recorder = &MockLoadPointerMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use
+func (m *MockLoadPointer) EXPECT() *MockLoadPointerMockRecorder {
+ return m.recorder
+}
+
+// Update mocks base method
+func (m *MockLoadPointer) Update(arg0 api.ChargeMode, arg1 float64) float64 {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Update", arg0, arg1)
+ ret0, _ := ret[0].(float64)
+ return ret0
+}
+
+// Update indicates an expected call of Update
+func (mr *MockLoadPointerMockRecorder) Update(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockLoadPointer)(nil).Update), arg0, arg1)
+}
diff --git a/provider/cache.go b/provider/cache.go
index 0ae5ab4da..cebd528f7 100644
--- a/provider/cache.go
+++ b/provider/cache.go
@@ -29,69 +29,69 @@ func NewCached(log *util.Logger, getter interface{}, cache time.Duration) *Cache
}
// FloatGetter gets float value
-func (c *Cached) FloatGetter() FloatGetter {
+func (c *Cached) FloatGetter() func() (float64, error) {
g, ok := c.getter.(func() (float64, error))
if !ok {
c.log.FATAL.Fatalf("invalid type: %T", c.getter)
}
- return FloatGetter(func() (float64, error) {
+ return func() (float64, error) {
if c.clock.Since(c.updated) > c.cache {
c.val, c.err = g()
c.updated = c.clock.Now()
}
return c.val.(float64), c.err
- })
+ }
}
// IntGetter gets int value
-func (c *Cached) IntGetter() IntGetter {
+func (c *Cached) IntGetter() func() (int64, error) {
g, ok := c.getter.(func() (int64, error))
if !ok {
c.log.FATAL.Fatalf("invalid type: %T", c.getter)
}
- return IntGetter(func() (int64, error) {
+ return func() (int64, error) {
if c.clock.Since(c.updated) > c.cache {
c.val, c.err = g()
c.updated = c.clock.Now()
}
return c.val.(int64), c.err
- })
+ }
}
// StringGetter gets string value
-func (c *Cached) StringGetter() StringGetter {
+func (c *Cached) StringGetter() func() (string, error) {
g, ok := c.getter.(func() (string, error))
if !ok {
c.log.FATAL.Fatalf("invalid type: %T", c.getter)
}
- return StringGetter(func() (string, error) {
+ return func() (string, error) {
if c.clock.Since(c.updated) > c.cache {
c.val, c.err = g()
c.updated = c.clock.Now()
}
return c.val.(string), c.err
- })
+ }
}
// BoolGetter gets bool value
-func (c *Cached) BoolGetter() BoolGetter {
+func (c *Cached) BoolGetter() func() (bool, error) {
g, ok := c.getter.(func() (bool, error))
if !ok {
- c.log.FATAL.Fatalf("invalid type: %T", g)
+ c.log.FATAL.Fatalf("invalid type: %T", c.getter)
}
- return BoolGetter(func() (bool, error) {
+ return func() (bool, error) {
if c.clock.Since(c.updated) > c.cache {
c.val, c.err = g()
c.updated = c.clock.Now()
}
return c.val.(bool), c.err
- })
+ }
}
diff --git a/provider/calc.go b/provider/calc.go
index a730bed9d..eec4e9649 100644
--- a/provider/calc.go
+++ b/provider/calc.go
@@ -5,11 +5,11 @@ import (
)
type calcProvider struct {
- add []FloatGetter
+ add []func() (float64, error)
}
// NewCalcFromConfig creates calc provider
-func NewCalcFromConfig(log *util.Logger, other map[string]interface{}) FloatGetter {
+func NewCalcFromConfig(log *util.Logger, other map[string]interface{}) func() (float64, error) {
cc := struct {
Add []Config
}{}
diff --git a/provider/config.go b/provider/config.go
index 6e7e7d3f4..37c4859a6 100644
--- a/provider/config.go
+++ b/provider/config.go
@@ -61,7 +61,7 @@ func scriptFromConfig(log *util.Logger, other map[string]interface{}) scriptConf
}
// NewFloatGetterFromConfig creates a FloatGetter from config
-func NewFloatGetterFromConfig(log *util.Logger, config Config) (res FloatGetter) {
+func NewFloatGetterFromConfig(log *util.Logger, config Config) (res func() (float64, error)) {
switch strings.ToLower(config.Type) {
case "calc":
res = NewCalcFromConfig(log, config.Other)
@@ -79,7 +79,7 @@ func NewFloatGetterFromConfig(log *util.Logger, config Config) (res FloatGetter)
res = NewCached(log, res, pc.Cache).FloatGetter()
}
case "modbus":
- res = FloatGetter(NewModbusFromConfig(log, config.Type, config.Other).FloatGetter)
+ res = NewModbusFromConfig(log, config.Other).FloatGetter
default:
log.FATAL.Fatalf("invalid provider type %s", config.Type)
}
@@ -88,7 +88,7 @@ func NewFloatGetterFromConfig(log *util.Logger, config Config) (res FloatGetter)
}
// NewIntGetterFromConfig creates a IntGetter from config
-func NewIntGetterFromConfig(log *util.Logger, config Config) (res IntGetter) {
+func NewIntGetterFromConfig(log *util.Logger, config Config) (res func() (int64, error)) {
switch strings.ToLower(config.Type) {
case "http":
res = NewHTTPProviderFromConfig(log, config.Other).IntGetter
@@ -103,8 +103,8 @@ func NewIntGetterFromConfig(log *util.Logger, config Config) (res IntGetter) {
if pc.Cache > 0 {
res = NewCached(log, res, pc.Cache).IntGetter()
}
- case "modbus-rtu", "modbus-tcp", "modbus-rtuovertcp", "modbus-tcprtu", "modbus-rtutcp":
- res = IntGetter(NewModbusFromConfig(log, config.Type, config.Other).IntGetter)
+ case "modbus":
+ res = NewModbusFromConfig(log, config.Other).IntGetter
default:
log.FATAL.Fatalf("invalid provider type %s", config.Type)
}
@@ -113,7 +113,7 @@ func NewIntGetterFromConfig(log *util.Logger, config Config) (res IntGetter) {
}
// NewStringGetterFromConfig creates a StringGetter from config
-func NewStringGetterFromConfig(log *util.Logger, config Config) (res StringGetter) {
+func NewStringGetterFromConfig(log *util.Logger, config Config) (res func() (string, error)) {
switch strings.ToLower(config.Type) {
case "http":
res = NewHTTPProviderFromConfig(log, config.Other).StringGetter
@@ -138,7 +138,7 @@ func NewStringGetterFromConfig(log *util.Logger, config Config) (res StringGette
}
// NewBoolGetterFromConfig creates a BoolGetter from config
-func NewBoolGetterFromConfig(log *util.Logger, config Config) (res BoolGetter) {
+func NewBoolGetterFromConfig(log *util.Logger, config Config) (res func() (bool, error)) {
switch strings.ToLower(config.Type) {
case "http":
res = NewHTTPProviderFromConfig(log, config.Other).BoolGetter
@@ -161,7 +161,7 @@ func NewBoolGetterFromConfig(log *util.Logger, config Config) (res BoolGetter) {
}
// NewIntSetterFromConfig creates a IntSetter from config
-func NewIntSetterFromConfig(log *util.Logger, param string, config Config) (res IntSetter) {
+func NewIntSetterFromConfig(log *util.Logger, param string, config Config) (res func(int64) error) {
switch strings.ToLower(config.Type) {
case "http":
res = NewHTTPProviderFromConfig(log, config.Other).IntSetter
@@ -179,7 +179,7 @@ func NewIntSetterFromConfig(log *util.Logger, param string, config Config) (res
}
// NewBoolSetterFromConfig creates a BoolSetter from config
-func NewBoolSetterFromConfig(log *util.Logger, param string, config Config) (res BoolSetter) {
+func NewBoolSetterFromConfig(log *util.Logger, param string, config Config) (res func(bool) error) {
switch strings.ToLower(config.Type) {
case "http":
res = NewHTTPProviderFromConfig(log, config.Other).BoolSetter
diff --git a/provider/modbus.go b/provider/modbus.go
index 14a4d39de..1ad0ac07f 100644
--- a/provider/modbus.go
+++ b/provider/modbus.go
@@ -23,7 +23,7 @@ type Modbus struct {
}
// NewModbusFromConfig creates Modbus plugin
-func NewModbusFromConfig(log *util.Logger, typ string, other map[string]interface{}) *Modbus {
+func NewModbusFromConfig(log *util.Logger, other map[string]interface{}) *Modbus {
cc := struct {
modbus.Settings `mapstructure:",squash"`
Register modbus.Register
@@ -102,7 +102,7 @@ func NewModbusFromConfig(log *util.Logger, typ string, other map[string]interfac
}
}
-// FloatGetter executes configured modbus read operation and implements provider.FloatGetter
+// FloatGetter executes configured modbus read operation and implements func() (float64, error)
func (m *Modbus) FloatGetter() (float64, error) {
m.conn.Slave(m.slaveID)
diff --git a/provider/mqtt.go b/provider/mqtt.go
index 952be7a19..afd149d8d 100644
--- a/provider/mqtt.go
+++ b/provider/mqtt.go
@@ -102,7 +102,7 @@ func (m *MqttClient) listen(topic string, callback func(string)) {
}
// FloatGetter creates handler for float64 from MQTT topic that returns cached value
-func (m *MqttClient) FloatGetter(topic string, scale float64, timeout time.Duration) FloatGetter {
+func (m *MqttClient) FloatGetter(topic string, scale float64, timeout time.Duration) func() (float64, error) {
h := &msgHandler{
log: m.log,
mux: util.NewWaiter(timeout, func() { m.log.TRACE.Printf("%s wait for initial value", topic) }),
@@ -115,7 +115,7 @@ func (m *MqttClient) FloatGetter(topic string, scale float64, timeout time.Durat
}
// IntGetter creates handler for int64 from MQTT topic that returns cached value
-func (m *MqttClient) IntGetter(topic string, scale int64, timeout time.Duration) IntGetter {
+func (m *MqttClient) IntGetter(topic string, scale int64, timeout time.Duration) func() (int64, error) {
h := &msgHandler{
log: m.log,
mux: util.NewWaiter(timeout, func() { m.log.TRACE.Printf("%s wait for initial value", topic) }),
@@ -128,7 +128,7 @@ func (m *MqttClient) IntGetter(topic string, scale int64, timeout time.Duration)
}
// StringGetter creates handler for string from MQTT topic that returns cached value
-func (m *MqttClient) StringGetter(topic string, timeout time.Duration) StringGetter {
+func (m *MqttClient) StringGetter(topic string, timeout time.Duration) func() (string, error) {
h := &msgHandler{
log: m.log,
mux: util.NewWaiter(timeout, func() { m.log.TRACE.Printf("%s wait for initial value", topic) }),
@@ -140,7 +140,7 @@ func (m *MqttClient) StringGetter(topic string, timeout time.Duration) StringGet
}
// BoolGetter creates handler for string from MQTT topic that returns cached value
-func (m *MqttClient) BoolGetter(topic string, timeout time.Duration) BoolGetter {
+func (m *MqttClient) BoolGetter(topic string, timeout time.Duration) func() (bool, error) {
h := &msgHandler{
log: m.log,
mux: util.NewWaiter(timeout, func() { m.log.TRACE.Printf("%s wait for initial value", topic) }),
@@ -163,7 +163,7 @@ func (m *MqttClient) formatValue(param, message string, v interface{}) (string,
}
// IntSetter publishes topic with parameter replaced by int value
-func (m *MqttClient) IntSetter(param, topic, message string) IntSetter {
+func (m *MqttClient) IntSetter(param, topic, message string) func(int64) error {
return func(v int64) error {
payload, err := m.formatValue(param, message, v)
if err != nil {
@@ -181,7 +181,7 @@ func (m *MqttClient) IntSetter(param, topic, message string) IntSetter {
}
// BoolSetter invokes script with parameter replaced by bool value
-func (m *MqttClient) BoolSetter(param, topic, message string) BoolSetter {
+func (m *MqttClient) BoolSetter(param, topic, message string) func(bool) error {
return func(v bool) error {
payload, err := m.formatValue(param, message, v)
if err != nil {
diff --git a/provider/openwb.go b/provider/openwb.go
index f43bd683b..de833a4be 100644
--- a/provider/openwb.go
+++ b/provider/openwb.go
@@ -6,10 +6,10 @@ import (
)
type openWBStatusProvider struct {
- plugged, charging BoolGetter
+ plugged, charging func() (bool, error)
}
-func openWBStatusFromConfig(log *util.Logger, other map[string]interface{}) StringGetter {
+func openWBStatusFromConfig(log *util.Logger, other map[string]interface{}) func() (string, error) {
cc := struct {
Plugged, Charging Config
}{}
diff --git a/provider/provider.go b/provider/provider.go
deleted file mode 100644
index 889dfd5f1..000000000
--- a/provider/provider.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package provider
-
-// Getters return typed device data.
-// They are used to abstract the underlying device implementation.
-
-type (
- // FloatGetter gets float value
- FloatGetter func() (float64, error)
- // IntGetter gets int value
- IntGetter func() (int64, error)
- // StringGetter gets string value
- StringGetter func() (string, error)
- // BoolGetter gets bool value
- BoolGetter func() (bool, error)
-)
-
-// Setters update typed device data.
-// They are used to abstract the underlying device implementation.
-
-type (
- // FloatSetter sets float value
- FloatSetter func(float64) error
- // IntSetter sets int value
- IntSetter func(int64) error
- // StringSetter sets string value
- StringSetter func(string) error
- // BoolSetter sets bool value
- BoolSetter func(bool) error
-)
diff --git a/provider/script.go b/provider/script.go
index fdae8fd42..1d6acef80 100644
--- a/provider/script.go
+++ b/provider/script.go
@@ -28,7 +28,7 @@ func NewScriptProvider(timeout time.Duration) *Script {
}
// StringGetter returns string from exec result. Only STDOUT is considered.
-func (e *Script) StringGetter(script string) StringGetter {
+func (e *Script) StringGetter(script string) func() (string, error) {
args, err := shellquote.Split(script)
if err != nil {
panic(err)
@@ -63,7 +63,7 @@ func (e *Script) StringGetter(script string) StringGetter {
}
// IntGetter parses int64 from exec result
-func (e *Script) IntGetter(script string) IntGetter {
+func (e *Script) IntGetter(script string) func() (int64, error) {
exec := e.StringGetter(script)
// return func to access cached value
@@ -78,7 +78,7 @@ func (e *Script) IntGetter(script string) IntGetter {
}
// FloatGetter parses float from exec result
-func (e *Script) FloatGetter(script string) FloatGetter {
+func (e *Script) FloatGetter(script string) func() (float64, error) {
exec := e.StringGetter(script)
// return func to access cached value
@@ -93,7 +93,7 @@ func (e *Script) FloatGetter(script string) FloatGetter {
}
// BoolGetter parses bool from exec result. "on", "true" and 1 are considered truish.
-func (e *Script) BoolGetter(script string) BoolGetter {
+func (e *Script) BoolGetter(script string) func() (bool, error) {
exec := e.StringGetter(script)
// return func to access cached value
@@ -108,7 +108,7 @@ func (e *Script) BoolGetter(script string) BoolGetter {
}
// IntSetter invokes script with parameter replaced by int value
-func (e *Script) IntSetter(param, script string) IntSetter {
+func (e *Script) IntSetter(param, script string) func(int64) error {
// return func to access cached value
return func(i int64) error {
cmd, err := util.ReplaceFormatted(script, map[string]interface{}{
@@ -128,7 +128,7 @@ func (e *Script) IntSetter(param, script string) IntSetter {
}
// BoolSetter invokes script with parameter replaced by bool value
-func (e *Script) BoolSetter(param, script string) BoolSetter {
+func (e *Script) BoolSetter(param, script string) func(bool) error {
// return func to access cached value
return func(b bool) error {
cmd, err := util.ReplaceFormatted(script, map[string]interface{}{
diff --git a/push/hub.go b/push/hub.go
index e43263a8b..39dcd2b2d 100644
--- a/push/hub.go
+++ b/push/hub.go
@@ -1,28 +1,27 @@
package push
-import "github.com/andig/evcc/util"
+import (
+ "github.com/andig/evcc/util"
+)
// Event is a notification event
type Event struct {
- Event string
- Attributes map[string]interface{}
-}
-
-// apply applies the event template to the content to produce the actual message
-func (e Event) apply(template string) (string, error) {
- return util.ReplaceFormatted(template, e.Attributes)
+ LoadPoint *int // optional loadpoint id
+ Event string
}
// Hub subscribes to event notifications and sends them to client devices
type Hub struct {
definitions map[string]EventTemplate
sender []Sender
+ cache *util.Cache
}
// NewHub creates push hub with definitions and receiver
-func NewHub(definitions map[string]EventTemplate) *Hub {
+func NewHub(definitions map[string]EventTemplate, cache *util.Cache) *Hub {
h := &Hub{
definitions: definitions,
+ cache: cache,
}
return h
}
@@ -32,6 +31,20 @@ func (h *Hub) Add(sender Sender) {
h.sender = append(h.sender, sender)
}
+// apply applies the event template to the content to produce the actual message
+func (h *Hub) apply(ev Event, template string) (string, error) {
+ attr := make(map[string]interface{})
+
+ // get all values from cache
+ for _, p := range h.cache.All() {
+ if p.LoadPoint == nil || ev.LoadPoint == p.LoadPoint {
+ attr[p.Key] = p.Val
+ }
+ }
+
+ return util.ReplaceFormatted(template, attr)
+}
+
// Run is the Hub's main publishing loop
func (h *Hub) Run(events <-chan Event) {
for ev := range events {
@@ -42,12 +55,13 @@ func (h *Hub) Run(events <-chan Event) {
definition, ok := h.definitions[ev.Event]
if !ok {
log.ERROR.Printf("invalid event %v", ev.Event)
- break
+ continue
}
- msg, err := ev.apply(definition.Msg)
+ msg, err := h.apply(ev, definition.Msg)
if err != nil {
- log.ERROR.Printf("invalid message template: %v", err)
+ log.ERROR.Printf("invalid template for %s: %v", ev.Event, err)
+ continue
}
for _, sender := range h.sender {
diff --git a/server/assets.go b/server/assets.go
index 910bf3983..56bcb9ca7 100644
--- a/server/assets.go
+++ b/server/assets.go
@@ -3561,102 +3561,108 @@ oJE6K0E084u3ea7ne0X73qsc2P2KOB+ngKecxITsMAgTVJfqMwAA//+21HeD5gEAAA==
"/index.html": {
name: "index.html",
local: "../assets/index.html",
- size: 13184,
+ size: 13271,
modtime: 1566640112,
compressed: `
-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
+H4sIAAAAAAAC/+Q72ZLbNrbv/RUwk7jtpCFK7SWOWlLKcXwr98ZOuW772lV3ah5A8ohEmgQ4AKil26qa
+f5h5nMf8Sf4kXzJ1wEUkRa22a6Zq+sEiwbPvAOnRvUD6ZpkCiUwST85G+ENiJsKxA8LBBWDB5IyQUQKG
+ET9iSoMZO5mZ0mfO+oFgCYydGYd5KpVxiC+FAWHGzpwHJhoHMOM+UHtzQbjghrOYap/FMB5cEB0pLm6o
+kXTKzVjIDcIBaF/x1HAparRfviMvIqZCIC+kMErGMagNVJaZSKoaFhMBDzfB0jQGmkiPx0Dn4FGWptRn
+KfNiqCEvQR+Gqg0zmaYeU1SbZYOGFzP/xpmcIZmYixuiIC6pGJn5EeU+6qn5LeixM3jWXwye9R0SKZiO
+He5Ltw3bS0Wh0ZpeTgN9O3Z4wkJwEagk+uhy8eiyTnLKZohB7YPj6Q2eLgZPu+jZB130Eib4FLSpI2lu
+oDcHr3q2gaNvCuPUkNiUKU5TLgQE1DCvp2chmjuWaux88cTzWPCkTUlHUhk/M6RNrZC7x/3NKEw0Gp77
+DOOQvuUxvEAmNdd+cek9+fa7b/dg+lJMeVhDQ8aeknMNKn/WWyTxBhETQQLUb7Oc2r8ingw3MUxg5vsj
+N7+2y/coJS+urwmlazvkOvtau56URhvF0l7CRc/X2imMhIGrI4C6I9ZoUykMm4OWCexCLPn/UHIppchT
+mmjlj51ftfvrXzJQS/qo97g36OmYJ5bor9qZjNwctAsrlWkK6iDQtZpeJoIYOpBKWZ8vuNRb5GT4bAfu
+uwz+53oL7iyDg0SdZUCVzEynZhYeLYyYhLhfEwUzUBpILFnARUiY4IkNNfK1a2F6qZKhAp0XpPwxBOTO
+PiRreBpwBT5eDUuiVxZmhUzdguvIzbvCyJPBcnJ2Ngr4jPDA1rA8UgSbET9mWo8dwWYeUyT/obBImQho
+HJYLMQ8jQ7wwv3BylUasiU09xURQpmlkTKqHrhtyE2Vez5eJa2u6i2HvTEa8RDawMDRVPGFqSaZMkymj
+MbApSRS9RIvyIlNYwdbLjJGixdvIMMTGUtS+HMYhATOseDZ2fBnHLNVQLmNTwtzMSfzCZs9j85qpmyx1
+SpMrzrAQYNuqeDUAc5DcYBCMnSmLkYFdjZmHefbWskdT8tD6r7AfBkjKtiiSF1CMp5SJQnE316q4Q3cW
+qKVipbvWiqK/O6SuBKgRKXAFm1WPCRnl8U1tUVkDUm4gQW75g9SjA4cYOXZcZ/KKBSBGbg2xRg5TbzQi
+iokQyJc8WFyQLy2J4Zj0XnFxo8lkUqTliRLgvXZHI0ueTCbOBK9xtfcWay2ZTPZIByL4aCmK7jH52f5m
+yjp+G1+2j+QBKeVyrTOce66zFKe7Kl8wcAI+K2OouBy5gs2K3qU1C3FQYdroychtLZyttcfJcW08e3dW
+UDwri2Oef5jU7oIaSNKYmSIQIfGw95drlnIjioVhXIAiyYKyzEhiS4MPwhQTIyGjaFCCB1ynMVvSxyQ1
+9JFDZpRPx45tp87k7s5erFYjNxoUqGmJGQMLOnkUNHTmVWTKa6SUVnVvRj0ugmHuFvRTiWqvK7vzJCxB
+bc/gSVgC4uUabqpYAk1Qu1RB53elD8tKaMvHjEKsocGz0JMnIZ3GGQ+czyJFWZmKCKi1vqrV5CG09rQt
+i8zIhPtjx6gMHGK71dhJpeZ5R2OelnFm4IoYmQ7JYwXJFVHYd4ak33tibxMuaAT52mW/ny7yJbtzGZJH
+uFKKiTn9piBOuKmyuhn3ZEanOIja5OMICImuzIZ3+TOHDFGv8xKZB/T8G3zQQysPb2CZw+FtaacGo4bB
+Ds6cisS23LG0i74WQMyWY2fQ7/f7lQr2CQZ8xAMobL/ZSCwZinNDlXF2jFFSVFGVKEunCgrU1m5O743J
++Zwpcb7R3QMs96ps7rDwY1ZMMkZxJkJMNezz5KVSUuEEgwyP5T/eyh9XceLaL8D7HHJThITFcZ1jvnN0
+Jj+9ffuG3N3V1rBSWPAKuRhYmpNJqU1ML0niUSxrshpNAq4TXvNrfZx4YeFq3cNWAQsR8SAAUXr3vuEJ
+6Kv6CLExRNQ6w0YY4NyIJdDqVkQgKld1kI20PyCQGRcHdIAyNHGriTMQ/pzMUgY7EkfJudM5UNHBZeeY
+5BlBQyWzlFRXxcyG8/E84qbhHOu0Gq7FkpmJuYBq6tURC+ScCimgStgC5Y75hs9giFqMx+dyOj1f1ehj
+2RZpVhpAsYBjZrA4g7Ejp1MkJ8XQj7l/k5sip+FMro1M12K6Vs7PJ7eQ88PlFnLeJTfSaJKoj88B5QKF
+IwHVSS7S5FpO7SRUT4AORATO0XISJWKcT7JN7M9uqoSLdHa+qoYRwwz0QsWD12BqVXmPDS2VLivm5I+z
+42suyDfkzbsTLPmaC55kSSf6ZzflJ7BjtxGPtuBJtvslU/ut1j3cn1AnscjW6+SomE6axZIkhj4mnlQB
+KOpJY2TSUcaxehL8SQL62GnP39HATtbWIXa4/vDB+Ukm4FRD9lqTGtnCRE3qz3D6H+T79VYsnAcI4MXS
+vzkfkpxdLFmQSi6M7sUgQhNNBmVZGqFr9ZwbP8JN0PqmY2LraCAdkq75V7YgybIwSKeKCPa0BL7MgXfG
+bx058ejlJvQbOQdFJqRfBewPcJuFZPcBDFNKzmkg5yIfjGrx1cWyvgN5yYVOgetMHMYkSzdYFFu9y5Lk
+3d00MQ9aOj1crcq5rM4jyQwEGF/TxPyf4F1o7xsD2sjNGZ3mkHS20x2Tl+oWsnCvLaw9dCbaxHPvjdB7
+aKP95ilQjjPOGulTmsZjxoBa7rbPDxaIww7zOFXpayd4I1DPhw22VqNR/4Kc1yKtC2bSXx0VgHXc48zc
+wjzC1vUyMrisn1lg+XtMUg9LC/5TndF2VUSSl0QsTrsqIjmlJFZ0yq30g2rlggcPcVPdZrg+lsAHY6d6
+Uu6yqwXcZOOGeuRWS8jzlDa3prnR69qt7pAud1Bze8UCSDNxYzY6XPmzp6G0Ouq6i3SlVMldS/9tIcB/
+scjWIae2f2uilq8eqgOZ6njlcbq4IlMpDNX8FoaDfv+rK5IwFeJOTqbDwdPqsKWbJPWYcoiS9pinWLNL
+ebG4V0m7NdFrQ9N54x2JNoqnEFTB7EdWrvBiK0b5VmU3iheWBWgNJ4UA30DQBrTpdj4k9x60AMmHDy0e
+Dxu4Nos1+FIEOaPjKKw2zFV47q44BjtHV+GuqwyIkgj5njh2b+OQIdng+T1xZqC8TBQATghGgRDGediI
+nqOdjUNCrT1o6RffBUzId/+mrr/XMtz9+9ujYa8/Wmp/c95wTrW+Wn31sVb+DzDmg5bVRv2H5HvSJ9vM
+vGObdMBcs63UvgKuTSbC/VOD1RpOGBqaiO/bZ4v2lVV9I1mn5Tb3jpvyvMiUAmF2S/S85Lk+Oz92Ltw6
+HQtQIYdDzRdY+OUpBlyjvo92zV0focyPLAO1Q5XrSCrzY/EqsCFcufhwtfrEMv0vaHML3Jwk1kttbJJv
+Eav9c8wUlr8ePeIk2Mb5tpd/3S8MW69ej3gPOPkRDOOxJgEoUs5O9IK8Z3HsyQUlmQjI///+WxSDuqlz
+6eF0Z9Nkwx87zk72nD4fd3TSfSbUKU70ZCv7/Nxmax9qhWC6pI9q4v4C5vY2t87wrPO0q77F23rA8cc/
+/rZx9rWLiD2EuJ8ETEcbLz1O0uPNu9LJR6tRHQv8y5UoN9inOqS5i/8s6mzE6PE7yGrpkF1khw33ZOee
+/NzM0Irnrj1gl19rptiZsAel7DFxQghKuBElB8TJWte8bWwPlZODpWWl4/R6EzEN4jCVGq5LEVGvVukn
+FOY1Fz1ybZRMThAo4aKY2Far559SKLb4CKHY4kChPndw29cBUSsxW1xQdxJdbs3zA3Id/8qhoInXFK9r
+YZfO3V7bcJZ1mAwgjoet1QMdVjsX+uOvf7flqJ2mHaIfLNvPLGW33Pz+mzldvhcsZT43y9Xqxo7r+8Vr
+h0ZH3DVDsGNsnUppQFV77eJg9RJHzCSgT/AmP30r7osYMjLdP792fnKw45Cv8YHHtq3OfV+myyty2b/s
+t/eFu94K5nquB3V8OM2E/aKY5B/32U8xHzwsvj1WYDIlyJ8K4q67/xvOcodRaWH74JA4Gx9jOutdf/mx
+XR3sulhrQvIkrAP9dxK2nttP1Rog+ddtDSh8UIdB4RGiAFhdNBTOPwu1S3++OiNk1bXnqb4/T9P2B+Ej
+N/8Se+Tm/43nnwEAAP//RNRilNczAAA=
`,
},
"/js/app.js": {
name: "app.js",
local: "../assets/js/app.js",
- size: 5986,
+ size: 6586,
modtime: 1566640112,
compressed: `
-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
-338DAAD//+5pAIpiFwAA
+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
`,
},
diff --git a/server/http.go b/server/http.go
index 0e62ce8f4..8c4f4a659 100644
--- a/server/http.go
+++ b/server/http.go
@@ -9,6 +9,7 @@ import (
"github.com/andig/evcc/api"
"github.com/andig/evcc/core"
+ "github.com/andig/evcc/util"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
)
@@ -29,7 +30,7 @@ type MenuConfig struct {
}
type chargeModeJSON struct {
- Mode string `json:"mode"`
+ Mode api.ChargeMode `json:"mode"`
}
type route struct {
@@ -38,11 +39,11 @@ type route struct {
HandlerFunc http.HandlerFunc
}
-// loadPoint is the minimal interface for accessing loadpoint methods
-type loadPoint interface {
+// site is the minimal interface for accessing site methods
+type site interface {
GetMode() api.ChargeMode
SetMode(api.ChargeMode)
- Configuration() core.Configuration
+ Configuration() core.SiteConfiguration
}
// routeLogger traces matched routes including their executing time
@@ -93,46 +94,47 @@ func jsonHandler(h http.Handler) http.Handler {
})
}
+func jsonResponse(w http.ResponseWriter, r *http.Request, content interface{}) {
+ w.WriteHeader(http.StatusOK)
+ if err := json.NewEncoder(w).Encode(content); err != nil {
+ log.ERROR.Printf("httpd: failed to encode JSON: %v", err)
+ }
+}
+
// HealthHandler returns current charge mode
-func HealthHandler(lp loadPoint) http.HandlerFunc {
+func HealthHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
res := struct{ OK bool }{OK: true}
-
- w.WriteHeader(http.StatusOK)
- if err := json.NewEncoder(w).Encode(res); err != nil {
- log.ERROR.Printf("httpd: failed to encode JSON: %v", err)
- }
+ jsonResponse(w, r, res)
}
}
// ConfigHandler returns current charge mode
-func ConfigHandler(lp loadPoint) http.HandlerFunc {
+func ConfigHandler(site site) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- res := lp.Configuration()
+ res := site.Configuration()
+ jsonResponse(w, r, res)
+ }
+}
- w.WriteHeader(http.StatusOK)
- if err := json.NewEncoder(w).Encode(res); err != nil {
- log.ERROR.Printf("httpd: failed to encode JSON: %v", err)
- }
+// StateHandler returns current charge mode
+func StateHandler(cache *util.Cache) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ res := cache.State()
+ jsonResponse(w, r, res)
}
}
// CurrentChargeModeHandler returns current charge mode
-func CurrentChargeModeHandler(lp loadPoint) http.HandlerFunc {
+func CurrentChargeModeHandler(site site) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- res := chargeModeJSON{
- Mode: string(lp.GetMode()),
- }
-
- w.WriteHeader(http.StatusOK)
- if err := json.NewEncoder(w).Encode(res); err != nil {
- log.ERROR.Printf("httpd: failed to encode JSON: %v", err)
- }
+ res := chargeModeJSON{Mode: site.GetMode()}
+ jsonResponse(w, r, res)
}
}
// ChargeModeHandler updates charge mode
-func ChargeModeHandler(lp loadPoint) http.HandlerFunc {
+func ChargeModeHandler(site site) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@@ -142,16 +144,10 @@ func ChargeModeHandler(lp loadPoint) http.HandlerFunc {
return
}
- lp.SetMode(api.ChargeMode(mode))
+ site.SetMode(api.ChargeMode(mode))
- res := chargeModeJSON{
- Mode: string(lp.GetMode()),
- }
-
- w.WriteHeader(http.StatusOK)
- if err := json.NewEncoder(w).Encode(res); err != nil {
- log.ERROR.Printf("httpd: failed to encode JSON: %v", err)
- }
+ res := chargeModeJSON{Mode: site.GetMode()}
+ jsonResponse(w, r, res)
}
}
@@ -163,15 +159,17 @@ func SocketHandler(hub *SocketHub) http.HandlerFunc {
}
// NewHTTPd creates HTTP server with configured routes for loadpoint
-func NewHTTPd(url string, links []MenuConfig, lp loadPoint, hub *SocketHub) *http.Server {
+func NewHTTPd(url string, links []MenuConfig, site site, hub *SocketHub, cache *util.Cache) *http.Server {
var routes = []route{{
- []string{"GET"}, "/health", HealthHandler(lp),
+ []string{"GET"}, "/health", HealthHandler(),
}, {
- []string{"GET"}, "/config", ConfigHandler(lp),
+ []string{"GET"}, "/config", ConfigHandler(site),
}, {
- []string{"GET"}, "/mode", CurrentChargeModeHandler(lp),
+ []string{"GET"}, "/state", StateHandler(cache),
}, {
- []string{"PUT", "POST", "OPTIONS"}, "/mode/{mode:[a-z]+}", ChargeModeHandler(lp),
+ []string{"GET"}, "/mode", CurrentChargeModeHandler(site),
+ }, {
+ []string{"PUT", "POST", "OPTIONS"}, "/mode/{mode:[a-z]+}", ChargeModeHandler(site),
}}
router := mux.NewRouter().StrictSlash(true)
diff --git a/server/influxdb.go b/server/influxdb.go
index 0c6bc3030..fb86b130a 100644
--- a/server/influxdb.go
+++ b/server/influxdb.go
@@ -5,6 +5,7 @@ import (
"sync"
"time"
+ "github.com/andig/evcc/core"
"github.com/andig/evcc/util"
influxdb2 "github.com/influxdata/influxdb-client-go"
)
@@ -49,7 +50,7 @@ func NewInfluxClient(url, token, org, user, password, database string) *Influx {
}
// Run Influx publisher
-func (m *Influx) Run(in <-chan util.Param) {
+func (m *Influx) Run(loadPoints []*core.LoadPoint, in <-chan util.Param) {
writer := m.client.WriteApi(m.org, m.database)
// log errors
@@ -67,8 +68,8 @@ func (m *Influx) Run(in <-chan util.Param) {
}
tags := map[string]string{}
- if param.LoadPoint != "" {
- tags["loadpoint"] = param.LoadPoint
+ if param.LoadPoint != nil {
+ tags["loadpoint"] = loadPoints[*param.LoadPoint].Name()
}
fields := map[string]interface{}{
diff --git a/server/limiter.go b/server/limiter.go
index bc00f298a..9790bfc22 100644
--- a/server/limiter.go
+++ b/server/limiter.go
@@ -43,8 +43,7 @@ func NewDeduplicator(interval time.Duration, filter ...string) Piper {
func (l *Deduplicator) pipe(in <-chan util.Param, out chan<- util.Param) {
for p := range in {
- // use loadpoint + param.Key as lookup key to value cache
- key := p.LoadPoint + "." + p.Key
+ key := p.UniqueID()
item, cached := l.cache[key]
_, filtered := l.filter[p.Key]
@@ -84,8 +83,7 @@ func NewLimiter(interval time.Duration) Piper {
func (l *Limiter) pipe(in <-chan util.Param, out chan<- util.Param) {
for p := range in {
- // use loadpoint + param.Key as lookup key to value cache
- key := p.LoadPoint + "." + p.Key
+ key := p.UniqueID()
item, cached := l.cache[key]
// forward if not cached or expired
diff --git a/server/socket.go b/server/socket.go
index 314a7dfde..be4e377af 100644
--- a/server/socket.go
+++ b/server/socket.go
@@ -34,7 +34,10 @@ type SocketClient struct {
// writePump pumps messages from the hub to the websocket connection.
func (c *SocketClient) writePump() {
- defer c.conn.Close()
+ defer func() {
+ c.conn.Close()
+ c.hub.unregister <- c
+ }()
for msg := range c.send {
if err := c.conn.SetWriteDeadline(time.Now().Add(socketWriteTimeout)); err != nil {
@@ -108,53 +111,49 @@ func kv(i util.Param) string {
return "\"" + i.Key + "\":" + val
}
-func (h *SocketHub) welcome(client *SocketClient, params []util.Param) {
+// paramToJSON converts util.Param to JSON as expected by UI
+func paramToJSON(p util.Param) string {
var msg strings.Builder
- // build json object
- _, _ = msg.WriteString("{")
- for _, p := range params {
- if p.Key == "error" || p.Key == "warn" {
- continue
- }
- if msg.Len() > 1 {
- _, _ = msg.WriteString(",")
- }
- msg.WriteString(kv(p))
+ msg.WriteString("{")
+ if p.LoadPoint != nil {
+ msg.WriteString(fmt.Sprintf("\"loadpoint\":%d,", *p.LoadPoint))
}
- _, _ = msg.WriteString("}")
+ msg.WriteString(kv(p))
+ msg.WriteString("}")
- // add client if send successful
- select {
- case client.send <- []byte(msg.String()):
- h.clients[client] = true
- default:
- close(client.send)
+ return msg.String()
+}
+
+func (h *SocketHub) welcome(client *SocketClient, params []util.Param) {
+ h.clients[client] = true
+
+ for _, p := range params {
+ msg := paramToJSON(p)
+ select {
+ case client.send <- []byte(msg):
+ default:
+ close(client.send)
+ }
}
}
func (h *SocketHub) broadcast(p util.Param) {
if len(h.clients) > 0 {
- message := fmt.Sprintf("{%s}", kv(p))
+ msg := paramToJSON(p)
for client := range h.clients {
select {
- case client.send <- []byte(message):
+ case client.send <- []byte(msg):
default:
- close(client.send)
- delete(h.clients, client)
+ h.unregister <- client
}
}
}
}
-// Cacher gives access to current cache state
-type Cacher interface {
- All() []util.Param
-}
-
// Run starts data and status distribution
-func (h *SocketHub) Run(in <-chan util.Param, cache Cacher) {
+func (h *SocketHub) Run(in <-chan util.Param, cache *util.Cache) {
for {
select {
case client := <-h.register:
diff --git a/server/socket_test.go b/server/socket_test.go
index 3d37bee76..028f25cce 100644
--- a/server/socket_test.go
+++ b/server/socket_test.go
@@ -6,21 +6,13 @@ import (
"time"
)
-type nilVal int
-
-func (n *nilVal) String() string {
- return "—"
-}
-
func TestEncode(t *testing.T) {
- var nv nilVal
tc := []struct {
in, out interface{}
}{
{int64(1), "1"},
{float64(1.2345), "1.234"},
{"1.2345", "\"1.2345\""},
- {&nv, "\"—\""},
{time.Duration(time.Hour), "3600"},
{"minpv", "\"minpv\""},
}
diff --git a/util/cache.go b/util/cache.go
index 513aef536..23dfbafac 100644
--- a/util/cache.go
+++ b/util/cache.go
@@ -1,35 +1,72 @@
package util
-import "sync"
+import (
+ "sync"
+)
// Cache is a data store
type Cache struct {
sync.Mutex
- val map[string]interface{}
+ val map[string]Param
}
// NewCache creates cache
func NewCache() *Cache {
- return &Cache{val: make(map[string]interface{})}
+ return &Cache{
+ val: make(map[string]Param),
+ }
}
// Run adds input channel's values to cache
func (c *Cache) Run(in <-chan Param) {
+ log := NewLogger("cache")
+
for p := range in {
- c.Add(p.Key, p)
+ log.TRACE.Printf("%s: %v", p.Key, p.Val)
+ c.Add(p.UniqueID(), p)
}
}
+// State provides a structured copy of the cached values
+// Loadpoints are aggregated as loadpoints array
+func (c *Cache) State() map[string]interface{} {
+ c.Lock()
+ defer c.Unlock()
+
+ res := map[string]interface{}{}
+ lps := make(map[int]map[string]interface{})
+
+ for _, param := range c.val {
+ if param.LoadPoint == nil {
+ res[param.Key] = param.Val
+ } else {
+ lp, ok := lps[*param.LoadPoint]
+ if !ok {
+ lp = make(map[string]interface{})
+ lps[*param.LoadPoint] = lp
+ }
+ lp[param.Key] = param.Val
+ }
+ }
+
+ // convert map to array
+ loadpoints := make([]map[string]interface{}, len(lps))
+ for id, lp := range lps {
+ loadpoints[id] = lp
+ }
+ res["loadpoints"] = loadpoints
+
+ return res
+}
+
// All provides a copy of the cached values
func (c *Cache) All() []Param {
c.Lock()
defer c.Unlock()
copy := make([]Param, 0, len(c.val))
- for _, v := range c.val {
- if param, ok := v.(Param); ok {
- copy = append(copy, param)
- }
+ for _, val := range c.val {
+ copy = append(copy, val)
}
return copy
@@ -49,9 +86,7 @@ func (c *Cache) Get(key string) Param {
defer c.Unlock()
if val, ok := c.val[key]; ok {
- if param, ok := val.(Param); ok {
- return param
- }
+ return val
}
return Param{}
diff --git a/util/format.go b/util/format.go
index 9042b66b4..5f1db3422 100644
--- a/util/format.go
+++ b/util/format.go
@@ -1,7 +1,6 @@
package util
import (
- "errors"
"fmt"
"regexp"
"strings"
@@ -16,14 +15,23 @@ func Truish(s string) bool {
// FormatValue will apply specific formatting in addition to standard sprintf
func FormatValue(format string, val interface{}) string {
- switch val := val.(type) {
+ switch typed := val.(type) {
case bool:
if format == "%d" {
- if val {
+ if typed {
return "1"
}
return "0"
}
+ case float64:
+ switch {
+ case strings.HasSuffix(format, "m"): // milli
+ format = format[:len(format)-1]
+ val = typed * 1e3
+ case strings.HasSuffix(format, "k"): // kilo
+ format = format[:len(format)-1]
+ val = typed / 1e3
+ }
}
if format == "" {
@@ -35,17 +43,34 @@ func FormatValue(format string, val interface{}) string {
// ReplaceFormatted replaces all occurrences of ${key} with formatted val from the kv map
func ReplaceFormatted(s string, kv map[string]interface{}) (string, error) {
+ wanted := make([]string, 0)
+
for m := re.FindStringSubmatch(s); m != nil; m = re.FindStringSubmatch(s) {
+ match, key, format := m[0], m[1], m[3]
+
// find key and replacement value
- val, ok := kv[m[1]]
+ val, ok := kv[key]
if !ok {
- return "", errors.New("could find value for: " + m[0])
+ wanted = append(wanted, key)
+ format = "%s"
+ val = "?"
}
// update all literal matches
- new := FormatValue(m[3], val)
- s = strings.ReplaceAll(s, m[0], new)
+ new := FormatValue(format, val)
+ s = strings.ReplaceAll(s, match, new)
}
- return s, nil
+ // return missing keys
+ var err error
+ if len(wanted) > 0 {
+ got := make([]string, 0)
+ for k := range kv {
+ got = append(got, k)
+ }
+
+ err = fmt.Errorf("wanted: %v, got: %v", wanted, got)
+ }
+
+ return s, err
}
diff --git a/util/format_test.go b/util/format_test.go
index e8085c2ae..91de2ec98 100644
--- a/util/format_test.go
+++ b/util/format_test.go
@@ -65,7 +65,7 @@ func TestReplaceNoMatch(t *testing.T) {
"bar": "baz",
})
- if s != "" || err == nil {
+ if err == nil {
t.Error(s, err)
}
}
diff --git a/util/log.go b/util/log.go
index dcb47640b..3f9ba26d1 100644
--- a/util/log.go
+++ b/util/log.go
@@ -84,9 +84,8 @@ func (w *uiWriter) Write(p []byte) (n int, err error) {
s := string(w.re.ReplaceAll(p, []byte{}))
uiChan <- Param{
- LoadPoint: "",
- Key: w.level,
- Val: strings.Trim(strconv.Quote(s), "\""),
+ Key: w.level,
+ Val: strings.Trim(strconv.Quote(s), "\"\n"),
}
return 0, nil
diff --git a/util/param.go b/util/param.go
index b5ff544f7..513fbd0f5 100644
--- a/util/param.go
+++ b/util/param.go
@@ -1,8 +1,19 @@
package util
+import "strconv"
+
// Param is the broadcast channel data type
type Param struct {
- LoadPoint string
+ LoadPoint *int
Key string
Val interface{}
}
+
+// UniqueID returns unique identifier for parameter LoadPoint/Key combination
+func (p Param) UniqueID() string {
+ key := p.Key
+ if p.LoadPoint != nil {
+ key = strconv.Itoa(*p.LoadPoint) + "." + key
+ }
+ return key
+}
diff --git a/vehicle/audi.go b/vehicle/audi.go
index 62ddeca45..42162d9d0 100644
--- a/vehicle/audi.go
+++ b/vehicle/audi.go
@@ -50,7 +50,7 @@ type Audi struct {
user, password, vin string
token string
tokenValid time.Time
- chargeStateG provider.FloatGetter
+ chargeStateG func() (float64, error)
}
// NewAudiFromConfig creates a new vehicle
diff --git a/vehicle/bmw.go b/vehicle/bmw.go
index 52dccb398..153d3218b 100644
--- a/vehicle/bmw.go
+++ b/vehicle/bmw.go
@@ -32,7 +32,7 @@ type BMW struct {
user, password, vin string
token string
tokenValid time.Time
- chargeStateG provider.FloatGetter
+ chargeStateG func() (float64, error)
}
// NewBMWFromConfig creates a new vehicle
diff --git a/vehicle/nissan.go b/vehicle/nissan.go
index 53c4a18c5..c4b35caa8 100644
--- a/vehicle/nissan.go
+++ b/vehicle/nissan.go
@@ -14,7 +14,7 @@ import (
type Nissan struct {
*embed
session *carwings.Session
- chargeStateG provider.FloatGetter
+ chargeStateG func() (float64, error)
}
// NewNissanFromConfig creates a new vehicle
diff --git a/vehicle/porsche.go b/vehicle/porsche.go
index 53d0d5404..9f12f9006 100644
--- a/vehicle/porsche.go
+++ b/vehicle/porsche.go
@@ -55,7 +55,7 @@ type Porsche struct {
user, password, vin string
token string
tokenValid time.Time
- chargeStateG provider.FloatGetter
+ chargeStateG func() (float64, error)
}
// NewPorscheFromConfig creates a new vehicle
diff --git a/vehicle/renault.go b/vehicle/renault.go
index 3c6ff4820..27e9600f7 100644
--- a/vehicle/renault.go
+++ b/vehicle/renault.go
@@ -90,7 +90,7 @@ type Renault struct {
gigya, kamereon configServer
gigyaJwtToken string
accountID string
- chargeStateG provider.FloatGetter
+ chargeStateG func() (float64, error)
}
// NewRenaultFromConfig creates a new vehicle
diff --git a/vehicle/tesla.go b/vehicle/tesla.go
index 3f9eec79d..a706f0e24 100644
--- a/vehicle/tesla.go
+++ b/vehicle/tesla.go
@@ -13,8 +13,8 @@ import (
type Tesla struct {
*embed
vehicle *tesla.Vehicle
- chargeStateG provider.FloatGetter
- chargedEnergyG provider.FloatGetter
+ chargeStateG func() (float64, error)
+ chargedEnergyG func() (float64, error)
}
// NewTeslaFromConfig creates a new Tesla vehicle
diff --git a/vehicle/vehicle.go b/vehicle/vehicle.go
index 7665a0d94..3bc19b739 100644
--- a/vehicle/vehicle.go
+++ b/vehicle/vehicle.go
@@ -26,7 +26,7 @@ func (m *embed) Capacity() int64 {
// Vehicle is an api.Vehicle implementation with configurable getters and setters.
type Vehicle struct {
*embed
- chargeG provider.FloatGetter
+ chargeG func() (float64, error)
}
// NewConfigurableFromConfig creates a new Vehicle