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: ` -H4sIAAAAAAAC/+xb2XLcNta+11PATP4oqQhNrU7+dpNTiePZErtcY49dNVNzAZKnSURYOADY3ZKqn2Ju -5zJvkjeZJ5kCwLXZklqypVQqo5smwbPgHHxnAUjNnmQyNRcloMJwFu/N7A9iRORRACKwA0CyeA+hGQdD -UFoQpcFEQWXm+OugeyAIhyhYUFiWUpkApVIYECYKljQzRZTBgqaA3c0BooIaShjWKWEQHR0gXSgqzrGR -eE5NJORIcAY6VbQ0VIqe7Bfv0POCqBzQcymMkoyBGrGSyhRS9biIyGg+JitLBpjLhDLAS0gwKUuckpIk -DHrMF6B3Y9WGmErjhCiszcVARsJIeh7Ee1YMo+IcKWCNFCOrtMA0tXZqegk6Co6+PlwdfX0YoELBPApo -KsNN2kkpaos6eV6GXdsooJzkEFqiRujJ8erkuC9yThaWA7sHd5d39HR19HSbPPdgmzxOBJ2DNn0mTQ1M -lpC0z0Y8+rx2To+JzImiuKRCQIYNSSZ6kVt3M6mi4JOzJCHZ2aYkXUhl0sqgTWn1vCc0HaOQa+t4mhKL -Q/yWMnhulfSW9pPj5Oyr///qFs5UijnNe2xWcaLkUoPyzyYrzkZCTAEccLqpcu7+ajwZahjEsEjTWeiv -3fATjNHzN28Qxp0fvM2p1mEipdFGkXLCqZikWge1kyxwdQHQX4iObS6FIUvQksNNjI3+bxstzSx8SCOt -0ij4UYc//rMCdYFPJqeTo4lmlDuhP+ognoWedBtXKcsS1E6knZlJJTIGW5iauX6zolJfM09in93A+66C -P7+5hndRwU5TXVSAlazMVstmoU/Ks0RmF/He3iyjC0Qzl0L8QgmyQCkjWkeBIIuEKOR/MKxKIjLM8maA -0bwwKMn9heNGaEaG3DhRRGRNlBTGlHoahjk1RZVMUslDl1JDi7ogntGG2cDK4FJRTtQFmhON5gQzIHPE -FT62BtEaqKRWm1TGSLGh28g8t3m9Tj2eJkAZMaR+FgWpZIyUGpphWxNsaHgRr8jiG2ZeEnVelYHThBBR -lNg4tFWj1TUg9CTeYZBFwZwwq8CNMpJYmL916q0rae4iu/afXc+SXGOIz192OUsiasNDb1V9Z5ezZm0M -a5arM9Su95ZZtxPoCal5BVm0jxGaeXhhF9MdIaYGuNXmH5QJPgqQkVEQBvEPJAMxC3uMPXEW+bMZUkTk -gD6l2eoAfepETCM0+YGKc43iuI6Ke87A3utwNnPiURwHsb22o5O3NtWhOL5ldiCyD55Fnbzj791vpdzC -X6eX3CZyh5AKqdaVbTveVKVtrtp4scDJ6KLBUH05CwVZ1KVDa5LbPoFoo+NZuDGw11lvG7fOee5ur5a4 -1+QmH382qMMVNsBLRkwNROCJLb3NmJM8QLEwhApQiK8wqYxELjWkIEzdsCE0K44a8ozqkpELfIpKg08C -tMB0HgWumgXx1ZW7WK9nYXFUs5YNJwOSbdVRy9BV0opprq2kss17C5xQkU39sth1aljddet3yvOG1KVs -yvOG0F52dHNFOAxJ3VBL7e+aNWwyoUsfCwxMw0BnbSflOZ6zimbBg8yiyUw1Anolri01HkLdSru0SIzk -NI0CoyoIkGsCoqCUmtoQmSKSaMkqA8+QkeUUnSrgz5CydWeKDidn7pZTgQvwY8eHh+XKD7mNwxSd2JFm -mjamX9fCETVtVA9xjxZ4bvtAF3zUEgLXrdvsnX8WoKm1a79hphne/9I+mFgvT8/hwtPZ28ZPA0UDh+0c -Oa2I62LHya7rWgaMXETB0eHh4WFrgntiAV/QDOo5uh1dFKH9JVFiH/0O7dsl2UdTtO/K2H4wLjVOEbad -RRuTFohGSdHijiunqYVNq+tJo2tU/zNbEFRT/mGVMsJ9F2wUJSK3wWg7AfRCKalmoVd4V/3RtfrtKBX5 -DhN47ynHU+CEsb5Gv7UL4j++ffsaXV31xmwuceQtc93SDHuXxhqGjxFPsE18sm1eMqo57a18v+F47uh6 -9cXlCUdR0CwDUcde/JmhHPSzfpMxajN6tWMEA9tZ2iTpbKsxao1ra8woMewAdULFDjViCzRLRVMq8hqc -qFzhE1cabign2wtK28M0pWOzeHgCLrNKI1Lp5c8/FQwEqkSGXrxDC1A25OwAR5ZwIVVORI4uK6QNUQbE -pCsmPf9ymYFeUpMWtgp3N7WlxJCSCGDxLOyu7+1mmd2QTra6MrnRlX32xAicK1mVqL2q+1pU2oLNE9vZ -N651qO2xOiZZGUYFtBsDXZBMLrGQAtqcVrNckdTQBUytSVG0L+fz/XUf/VSUVeMLRTJqEwNhFUSBnM+t -MCmmKaPpufeKlxDEb4ws25Bwc3yoGQu53HXGQi63zdhKGAV8A2tMhZ0YyrDmfjrxGzl3LWI/7kdsltQz -eQENG/PBMcwZD+ogTkW52F+3/ZkhBia5otlL6MfyjZ5zMrb5zgu/i/deUnFn172kgvKKoy/R63eP6rsP -dtx2r93RZZtG7+CxV5W62VcfVmf8EUCXAGe+GdsoN0RlOIP03Kask1uyXioZPjoe+lrLdLQ+tS37lp5n -+Om+6y91FB0ftIOn7eDJwRbK04O+EJZvEcJyfNLRo/XWPb+1z5p22gBK8/6qbnpio+3z9fO0bVQu8CGa -S2Hw0rXmWEjFCQvib4kxoCjMwuK0J72rfFuV+eZioOp4QOD2ZagpVd3QgAmhq6t2KfxrgPW66db6HSCv -DGRB/H8brZmfaXE8uC9d/b+stCEia0v5yKjxvvsGzPy2IfKeMJbI1S+FkDk3n3uUpA4ir+US1Bc3AsUx -/VXQ7Yzvd4cRA6pNJXJ0+5HoMLf0NPq9yUdC4rZK8ZsH6Cswl4+Gzj4g7RpswHEAXrQLOPtCWmiO8Tha -fMeDYnS4ETHfwmV1G2CJUnKJM7kUHp2DSChHivuHWP7vBRW6BKpvjw2vqipvVvQxIqJc/C8eXDzYzuyX -ztXl4h55umPaMUe/UJdQ5beC0AFRV6LBi38RdOe83Idj34lKLq9tOVGDJdSAZCswlFza7ffxdWiQDD9F -nev8peq98PN/vyeF88h0p7XvhDbuKs6GOKgPsXt1TQoBqVu8BaikEt12c3Di/RnPiC6ejTYW9wrzx/Xh -H8Btoj+SC8f9S/ZCgMovusi4pl3p6N4Xo2j4NTjSNlAZqUB9TFd+V7+uG7iqGfxivf5VeegvoN2JzSVQ -8+BOeqEN5cTAHZzUeete23j/nvUOB8bXvUDcfrS58fr2Du8S4+/AEMo0ykC1ORMfoHqng91p8d/cybE6 -72vxZ8P1ZM/aE9kEH9t5nm5X1iiYhcXZuEyMoDRe7xqTJ9th9FJmwNh0b9uK3iJqK4bGSV/L9G37znUw -4t6WbDk6uqkK3GmGm8Z+T0pySc3PP5kHNvg5KUlKzcXA5mZwvUbnLinf3/RBGtoZSt1W/CGQ9Loguit8 -D+HY0mrQnU/9/XpdPjKMvKUV12lBmN3gP4bRgqaFQZSXDDgIQ2HzlP+DTHpJxcSVXG2U5A9pD6fieaUU -CIOeRJHNlTCnwvfzmxTrNfrmkdf2JVk9liPI6jZHtBQf7Ij7JYy6iD1UwnjXVMkHjZ96Xx//59//emQs -vQJzefnwFvbe+jy+je64/OFt9E3oR7LyptdLcykNqDY+bGg0QcIzfGZveGbjpb5PpMpAYSPLO33R0G36 -b9hHDDxy3SnIZ6ksL56h48Pjw81vT26y2NvZdeL24bwSqfuYyn8G6D7a/PwL5I/AFJhKCfT3WngY3v61 -Z0161Vrhjn2mKBh9thkctDTNZ3l9sjf12JCS8rxP9Ceebzx3H7UNSPx3cAMq+6BPYydvKWqC9cHAYP8B -qRv6x7M9hNbbNjXth+JlufmJ+Cz032zPQv//Nv8NAAD//0fUoZyAMwAA +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