diff --git a/api/api.go b/api/api.go index a5a45c26f..c9c578979 100644 --- a/api/api.go +++ b/api/api.go @@ -14,6 +14,11 @@ const ( ModePV ChargeMode = "pv" ) +// String implements Stringer +func (c ChargeMode) String() string { + return string(c) +} + // ChargeStatus is the EV's charging status from A to F type ChargeStatus string @@ -27,6 +32,11 @@ const ( StatusF ChargeStatus = "F" // Fzg. angeschlossen: ja Laden aktiv: nein - Fehler (Ausfall Wallbox) ) +// String implements Stringer +func (c ChargeStatus) String() string { + return string(c) +} + // Meter is able to provide current power in W type Meter interface { CurrentPower() (float64, error) diff --git a/assets/index.html b/assets/index.html index 4f4cc6c30..64e46a41f 100644 --- a/assets/index.html +++ b/assets/index.html @@ -41,6 +41,13 @@ .progress-bar-animated { animation-direction: reverse; } + .btn.caption { + opacity: 1; + } + .btn.first { + border-top-left-radius: .2rem; + border-bottom-left-radius: .2rem; + } @@ -128,66 +135,52 @@ - - + + + + + + + + + @@ -366,10 +448,10 @@
- © 2020 + <<.Version>> <>(<<.Commit>>)<>
- <<.Version>> <>(<<.Commit>>)<> + © 2020
diff --git a/assets/js/app.js b/assets/js/app.js index 699cd27aa..7e249c0d3 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -186,6 +186,7 @@ Vue.component('version', { watch: { "state.availableVersion": function () { if (this.installed != "<<.Version>>" && // go template parsed? + this.installed != "0.0.1-alpha" && // make used? this.state.availableVersion != this.installed) { $(this.$refs.bar).collapse("show"); } @@ -193,27 +194,6 @@ Vue.component('version', { } }); -Vue.component('modeswitch', { - template: '#mode-template', - data: function() { - return { - state: store.state // global state - }; - }, - computed: { - mode: { - get: function() { - return this.state.mode; - }, - set: function(mode) { - axios.post('mode/' + mode).then(function(response) { - this.state.mode = response.data.mode; - }.bind(this)).catch(toasts.error); - } - } - }, -}); - Vue.component("site", { template: "#site-template", mixins: [formatter], @@ -222,6 +202,11 @@ Vue.component("site", { state: store.state // global state }; }, + computed: { + multi: function() { + return this.state.loadpoints.length > 1 + } + }, methods: { connect: function() { const protocol = loc.protocol == "https:" ? "wss:" : "ws:"; @@ -249,15 +234,26 @@ Vue.component("site", { } }); +Vue.component("site-details", { + template: "#site-details-template", + props: ["state"], + mixins: [formatter] +}); + Vue.component("loadpoint", { template: "#loadpoint-template", - props: { state: Object }, + props: ["state", "id", "pv", "multi"], mixins: [formatter], data: function() { return { tickerHandle: null, }; }, + computed: { + hasTargetSoC: function () { + return this.state.socLevels != null && this.state.socLevels.length > 0; + }, + }, watch: { "state.chargeDuration": function() { window.clearInterval(this.tickerHandle); @@ -269,11 +265,65 @@ Vue.component("loadpoint", { } }, }, + methods: { + api: function (func) { + return "lp/" + this.id + "/" + func; + }, + targetMode: function (mode) { + axios.post(this.api("mode") + "/" + mode).then(function (response) { + this.state.mode = response.data.mode; + }.bind(this)).catch(toasts.error); + }, + targetSoC: function (soc) { + axios.post(this.api("targetsoc") + "/" + soc).then(function (response) { + this.state.targetSoC = response.data.targetSoC; + }.bind(this)).catch(toasts.error); + }, + }, destroyed: function() { window.clearInterval(this.tickerHandle); } }); +Vue.component("loadpoint-details", { + template: "#loadpoint-details-template", + props: ["state"], + mixins: [formatter] +}); + +Vue.component("vehicle", { + template: "#vehicle-template", + props: ["state"] +}); + +Vue.component("mode", { + template: "#mode-template", + props: ["mode", "pv", "caption"], + methods: { + targetMode: function (mode) { + this.$emit("updated", mode) + } + }, +}); + +Vue.component("soc", { + template: "#soc-template", + props: ["soc", "caption", "levels"], + computed: { + levelsOrDefault: function() { + if (this.levels == null || this.levels.length == 0) { + return []; // disabled, or use 30, 50, 80, 100 + } + return this.levels; + } + }, + methods: { + targetSoC: function (mode) { + this.$emit("updated", mode) + } + }, +}); + // // Routing // diff --git a/core/loadpoint.go b/core/loadpoint.go index 7142f4864..8526fbd91 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -1,6 +1,7 @@ package core import ( + "sync" "time" "github.com/andig/evcc/api" @@ -38,9 +39,14 @@ type LoadPoint struct { bus evbus.Bus // event bus pushChan chan<- push.Event // notifications uiChan chan<- util.Param // client push messages + lpChan chan<- *LoadPoint // update requests log *util.Logger // exposed public configuration + sync.Mutex // guard status + Mode api.ChargeMode `mapstructure:"mode"` // Charge mode, guarded by mutex + targetSoC int `mapstructure:"targetSoC"` // Target SoC, guarded by mutex + Title string `mapstructure:"title"` // UI title Phases int64 `mapstructure:"phases"` // Phases- required for converting power and current ChargerRef string `mapstructure:"charger"` // Charger reference @@ -49,7 +55,12 @@ type LoadPoint struct { ChargeMeterRef string `mapstructure:"charge"` // Charge meter reference } SoC struct { - AlwaysUpdate bool `mapstructure:"alwaysUpdate"` + AlwaysUpdate bool `mapstructure:"alwaysUpdate"` + Levels []int `mapstructure:"levels"` + } + OnDisconnect struct { + Mode api.ChargeMode `mapstructure:"mode"` // Charge mode to apply when car disconnected + TargetSoC int `mapstructure:"targetSoC"` // Target SoC to apply when car disconnected } Enable, Disable ThresholdConfig @@ -69,6 +80,7 @@ type LoadPoint struct { connectedTime time.Time // Time when vehicle was connected pvTimer time.Time // PV enabled/disable timer + socCharge float64 // Vehicle SoC chargedEnergy float64 // Charged energy while connected deltaChargedEnergy float64 // Charged energy for single cycle chargeDuration time.Duration // Charge duration @@ -79,6 +91,16 @@ func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[strin lp := NewLoadPoint(log) util.DecodeOther(log, other, &lp) + // workaround mapstructure + if lp.Mode == "0" { + lp.Mode = api.ModeOff + } + + lp.targetSoC = 100 + if len(lp.SoC.Levels) > 0 { + lp.targetSoC = lp.SoC.Levels[len(lp.SoC.Levels)-1] + } + if lp.Meters.ChargeMeterRef != "" { lp.chargeMeter = cp.Meter(lp.Meters.ChargeMeterRef) } @@ -116,6 +138,7 @@ func NewLoadPoint(log *util.Logger) *LoadPoint { log: log, // logger clock: clock, // mockable time bus: bus, // event bus + Mode: api.ModeOff, Phases: 1, status: api.StatusNone, HandlerConfig: HandlerConfig{ @@ -129,6 +152,50 @@ func NewLoadPoint(log *util.Logger) *LoadPoint { return lp } +// 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() + + lp.log.INFO.Printf("set charge mode: %s", string(mode)) + + // apply immediately + if lp.Mode != mode { + lp.Mode = mode + lp.publish("mode", mode) + lp.lpChan <- lp // request loadpoint update + } +} + +// GetTargetSoC returns loadpoint charge targetSoC +func (lp *LoadPoint) GetTargetSoC() int { + lp.Lock() + defer lp.Unlock() + return lp.targetSoC +} + +// SetTargetSoC sets loadpoint charge targetSoC +func (lp *LoadPoint) SetTargetSoC(targetSoC int) { + lp.Lock() + defer lp.Unlock() + + lp.log.INFO.Println("set target soc:", targetSoC) + + // apply immediately + if lp.targetSoC != targetSoC { + lp.targetSoC = targetSoC + lp.publish("targetSoC", targetSoC) + lp.lpChan <- lp // request loadpoint update + } +} + // configureChargerType ensures that chargeMeter, Rate and Timer can use charger capabilities func (lp *LoadPoint) configureChargerType(charger api.Charger) { // ensure charge meter exists @@ -214,6 +281,14 @@ func (lp *LoadPoint) evVehicleDisconnectHandler() { lp.publish("connectedDuration", lp.clock.Since(lp.connectedTime)) lp.notify(evVehicleDisconnect) + + // set default mode on disconnect + if lp.OnDisconnect.Mode != "" { + lp.SetMode(lp.OnDisconnect.Mode) + } + if lp.OnDisconnect.TargetSoC != 0 { + lp.SetTargetSoC(lp.OnDisconnect.TargetSoC) + } } // evChargeCurrentHandler updates the dummy charge meter's charge power. This simplifies the main flow @@ -248,14 +323,21 @@ func (lp *LoadPoint) Name() string { } // Prepare loadpoint configuration by adding missing helper elements -func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Event) { - lp.pushChan = pushChan +func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Event, lpChan chan<- *LoadPoint) { lp.uiChan = uiChan + lp.pushChan = pushChan + lp.lpChan = lpChan // event handlers _ = lp.bus.Subscribe(evChargeStart, lp.evChargeStartHandler) _ = lp.bus.Subscribe(evChargeStop, lp.evChargeStopHandler) + // publish initial values + lp.Lock() + lp.publish("mode", lp.Mode) + lp.publish("targetSoC", lp.targetSoC) + lp.Unlock() + // prepare charger status lp.handler.Prepare() } @@ -292,7 +374,7 @@ func (lp *LoadPoint) updateChargeStatus() error { } } - // changed to A - disconnected + // changed to A - disconnected if status == api.StatusA { lp.bus.Publish(evVehicleDisconnect) } @@ -474,8 +556,9 @@ func (lp *LoadPoint) publishSoC() { if lp.SoC.AlwaysUpdate || lp.connected() { f, err := lp.vehicle.ChargeState() if err == nil { - lp.log.DEBUG.Printf("vehicle soc: %.0f%%", f) - lp.publish("socCharge", f) + lp.socCharge = f + lp.log.DEBUG.Printf("vehicle soc: %.0f%%", lp.socCharge) + lp.publish("socCharge", lp.socCharge) lp.publish("chargeEstimate", lp.remainingChargeDuration(f)) return } @@ -487,7 +570,10 @@ func (lp *LoadPoint) publishSoC() { } // Update is the main control function. It reevaluates meters and charger state -func (lp *LoadPoint) Update(mode api.ChargeMode, sitePower float64) { +func (lp *LoadPoint) Update(sitePower float64) { + mode := lp.GetMode() + lp.publish("mode", string(mode)) + // read and publish meters first lp.updateChargeMeter() @@ -522,11 +608,14 @@ func (lp *LoadPoint) Update(mode api.ChargeMode, sitePower float64) { var err error // execute loading strategy - switch mode { - case api.ModeOff: + switch { + case lp.targetSoC > 0 && lp.vehicle != nil && lp.socCharge >= float64(lp.targetSoC): + err = lp.handler.Ramp(0) + + case mode == api.ModeOff: err = lp.handler.Ramp(0, true) - case api.ModeNow: + case mode == api.ModeNow: // ensure that new connections happen at min current current := lp.MinCurrent if lp.connected() { @@ -534,7 +623,7 @@ func (lp *LoadPoint) Update(mode api.ChargeMode, sitePower float64) { } err = lp.handler.Ramp(current, true) - case api.ModeMinPV, api.ModePV: + case mode == api.ModeMinPV || mode == api.ModePV: targetCurrent := lp.maxCurrent(mode, sitePower) if !lp.connected() { // ensure minimum current when not connected diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index 1de8ca529..e87520e87 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -160,7 +160,8 @@ func TestUpdate(t *testing.T) { tc.expect(handler) } - lp.Update(tc.mode, 0) + lp.Mode = tc.mode + lp.Update(0) ctrl.Finish() } diff --git a/core/site.go b/core/site.go index 1b8ba0568..f42f16aa9 100644 --- a/core/site.go +++ b/core/site.go @@ -1,7 +1,6 @@ package core import ( - "sync" "time" "github.com/andig/evcc/api" @@ -16,22 +15,21 @@ import ( // Updater abstracts the LoadPoint implementation for testing type Updater interface { - Update(api.ChargeMode, float64) + Update(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 + uiChan chan<- util.Param // client push messages + lpUpdateChan chan *LoadPoint + + 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 + 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 + Meters MetersConfig // Meter references // meters gridMeter api.Meter // Grid usage meter @@ -66,11 +64,6 @@ func NewSiteFromConfig( Voltage = site.Voltage site.loadpoints = loadpoints - // 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") @@ -94,10 +87,8 @@ func NewSiteFromConfig( // 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 + log: util.NewLogger("core"), + Voltage: 230, // V } return lp @@ -106,7 +97,6 @@ func NewSite() *Site { // 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"` @@ -115,6 +105,7 @@ type SiteConfiguration struct { // LoadpointConfiguration is the loadpoint feature structure type LoadpointConfiguration struct { + Mode string `json:"mode"` Title string `json:"title"` Phases int64 `json:"phases"` MinCurrent int64 `json:"minCurrent"` @@ -123,6 +114,7 @@ type LoadpointConfiguration struct { SoC bool `json:"soc"` SoCCapacity int64 `json:"socCapacity"` SoCTitle string `json:"socTitle"` + SoCLevels []int `json:"socLevels"` } func (lp *LoadPoint) hasChargeMeter() bool { @@ -139,7 +131,6 @@ func (site *Site) LoadPoints() []*LoadPoint { 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, @@ -147,6 +138,7 @@ func (site *Site) Configuration() SiteConfiguration { for _, lp := range site.loadpoints { lpc := LoadpointConfiguration{ + Mode: string(lp.GetMode()), Title: lp.Name(), Phases: lp.Phases, MinCurrent: lp.MinCurrent, @@ -158,6 +150,7 @@ func (site *Site) Configuration() SiteConfiguration { lpc.SoC = true lpc.SoCCapacity = lp.vehicle.Capacity() lpc.SoCTitle = lp.vehicle.Title() + lpc.SoCLevels = lp.SoC.Levels } c.LoadPoints = append(c.LoadPoints, lpc) @@ -169,7 +162,6 @@ func (site *Site) Configuration() SiteConfiguration { // 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]) @@ -192,36 +184,6 @@ func (site *Site) DumpConfig() { } } -// 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 @@ -300,17 +262,16 @@ func (site *Site) sitePower() (float64, error) { func (site *Site) update(lp Updater) { site.log.DEBUG.Println("----") - mode := site.GetMode() - site.publish("mode", string(mode)) if sitePower, err := site.sitePower(); err == nil { - lp.Update(mode, sitePower) + lp.Update(sitePower) } } // Prepare attaches communication channels to site and loadpoints func (site *Site) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Event) { site.uiChan = uiChan + site.lpUpdateChan = make(chan *LoadPoint) for id, lp := range site.loadpoints { lpUIChan := make(chan util.Param) @@ -330,7 +291,7 @@ func (site *Site) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Event) } }(id) - lp.Prepare(lpUIChan, lpPushChan) + lp.Prepare(lpUIChan, lpPushChan, site.lpUpdateChan) } } @@ -346,23 +307,18 @@ func (site *Site) loopLoadpoints(next chan<- Updater) { // 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 - loadpointChan := make(chan Updater) go site.loopLoadpoints(loadpointChan) + ticker := time.NewTicker(interval) + site.update(<-loadpointChan) // start immediately + for { select { case <-ticker.C: site.update(<-loadpointChan) - case <-site.triggerChan: - for range site.loadpoints { - site.update(<-loadpointChan) - } - ticker.Stop() - ticker = time.NewTicker(interval) + case lp := <-site.lpUpdateChan: + site.update(lp) } } } diff --git a/evcc.dist.yaml b/evcc.dist.yaml index 4e8e421fb..678353dea 100644 --- a/evcc.dist.yaml +++ b/evcc.dist.yaml @@ -231,8 +231,17 @@ site: loadpoints: - title: Ladepunkt # ui display name vehicle: audi + mode: pv soc: alwaysUpdate: false # set true to update vehicle soc even when disconnected + levels: # target soc levels for UI + - 30 + - 50 + - 80 + - 100 + onDisconnect: + mode: pv # switch back to pv mode + targetSoC: 100 # charge fully charger: wallbe # charger phases: 3 # ev phases (default 3) sensitivity: 1 # current raise/lower step size (default 10A) diff --git a/mock/mock_loadpoint.go b/mock/mock_loadpoint.go index 4b369eebb..75335d3b7 100644 --- a/mock/mock_loadpoint.go +++ b/mock/mock_loadpoint.go @@ -5,7 +5,6 @@ package mock import ( - api "github.com/andig/evcc/api" gomock "github.com/golang/mock/gomock" reflect "reflect" ) @@ -34,13 +33,13 @@ func (m *MockUpdater) EXPECT() *MockUpdaterMockRecorder { } // Update mocks base method -func (m *MockUpdater) Update(arg0 api.ChargeMode, arg1 float64) { +func (m *MockUpdater) Update(arg0 float64) { m.ctrl.T.Helper() - m.ctrl.Call(m, "Update", arg0, arg1) + m.ctrl.Call(m, "Update", arg0) } // Update indicates an expected call of Update -func (mr *MockUpdaterMockRecorder) Update(arg0, arg1 interface{}) *gomock.Call { +func (mr *MockUpdaterMockRecorder) Update(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockUpdater)(nil).Update), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockUpdater)(nil).Update), arg0) } diff --git a/server/assets.go b/server/assets.go index f89b4b5d0..a7424b1b3 100644 --- a/server/assets.go +++ b/server/assets.go @@ -3561,115 +3561,128 @@ oJE6K0E084u3ea7ne0X73qsc2P2KOB+ngKecxITsMAgTVJfqMwAA//+21HeD5gEAAA== "/index.html": { name: "index.html", local: "../assets/index.html", - size: 14298, + size: 17523, modtime: 1566640112, compressed: ` -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 `, }, "/js/app.js": { name: "app.js", local: "../assets/js/app.js", - size: 7225, + size: 8507, modtime: 1566640112, compressed: ` -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 -fwUAAP//HalhmTkcAAA= +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 `, }, diff --git a/server/http.go b/server/http.go index 1265ae76b..88653c38f 100644 --- a/server/http.go +++ b/server/http.go @@ -5,6 +5,7 @@ import ( "fmt" "html/template" "net/http" + "strconv" "time" "github.com/andig/evcc/api" @@ -33,6 +34,10 @@ type chargeModeJSON struct { Mode api.ChargeMode `json:"mode"` } +type targetSoCJSON struct { + TargetSoC int `json:"targetSoC"` +} + type route struct { Methods []string Pattern string @@ -41,9 +46,16 @@ type route struct { // site is the minimal interface for accessing site methods type site interface { + Configuration() core.SiteConfiguration + LoadPoints() []*core.LoadPoint +} + +// mode is the minimal interface for accessing loadpoint methods +type loadpoint interface { GetMode() api.ChargeMode SetMode(api.ChargeMode) - Configuration() core.SiteConfiguration + GetTargetSoC() int + SetTargetSoC(targetSoC int) } // routeLogger traces matched routes including their executing time @@ -128,15 +140,15 @@ func StateHandler(cache *util.Cache) http.HandlerFunc { } // CurrentChargeModeHandler returns current charge mode -func CurrentChargeModeHandler(site site) http.HandlerFunc { +func CurrentChargeModeHandler(loadpoint loadpoint) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - res := chargeModeJSON{Mode: site.GetMode()} + res := chargeModeJSON{Mode: loadpoint.GetMode()} jsonResponse(w, r, res) } } // ChargeModeHandler updates charge mode -func ChargeModeHandler(site site) http.HandlerFunc { +func ChargeModeHandler(loadpoint loadpoint) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) @@ -146,9 +158,37 @@ func ChargeModeHandler(site site) http.HandlerFunc { return } - site.SetMode(api.ChargeMode(mode)) + loadpoint.SetMode(api.ChargeMode(mode)) - res := chargeModeJSON{Mode: site.GetMode()} + res := chargeModeJSON{Mode: loadpoint.GetMode()} + jsonResponse(w, r, res) + } +} + +// CurrentTargetSoCHandler returns current target soc +func CurrentTargetSoCHandler(loadpoint loadpoint) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + res := targetSoCJSON{TargetSoC: loadpoint.GetTargetSoC()} + jsonResponse(w, r, res) + } +} + +// TargetSoCHandler updates target soc +func TargetSoCHandler(loadpoint loadpoint) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + socS, ok := vars["soc"] + soc, err := strconv.ParseInt(socS, 10, 32) + + if !ok || err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + loadpoint.SetTargetSoC(int(soc)) + + res := targetSoCJSON{TargetSoC: loadpoint.GetTargetSoC()} jsonResponse(w, r, res) } } @@ -162,17 +202,15 @@ func SocketHandler(hub *SocketHub) http.HandlerFunc { // NewHTTPd creates HTTP server with configured routes for loadpoint func NewHTTPd(url string, links []MenuConfig, site site, hub *SocketHub, cache *util.Cache) *http.Server { - var routes = []route{{ - []string{"GET"}, "/health", HealthHandler(), - }, { - []string{"GET"}, "/config", ConfigHandler(site), - }, { - []string{"GET"}, "/state", StateHandler(cache), - }, { - []string{"GET"}, "/mode", CurrentChargeModeHandler(site), - }, { - []string{"PUT", "POST", "OPTIONS"}, "/mode/{mode:[a-z]+}", ChargeModeHandler(site), - }} + var routes = map[string]route{ + "health": {[]string{"GET"}, "/health", HealthHandler()}, + "config": {[]string{"GET"}, "/config", ConfigHandler(site)}, + "state": {[]string{"GET"}, "/state", StateHandler(cache)}, + "getmode": {[]string{"GET"}, "/mode", CurrentChargeModeHandler(site.LoadPoints()[0])}, + "setmode": {[]string{"PUT", "POST", "OPTIONS"}, "/mode/{mode:[a-z]+}", ChargeModeHandler(site.LoadPoints()[0])}, + "gettargetsoc": {[]string{"GET"}, "/targetsoc", CurrentTargetSoCHandler(site.LoadPoints()[0])}, + "settargetsoc": {[]string{"PUT", "POST", "OPTIONS"}, "/targetsoc/{soc:[0-9]+}", TargetSoCHandler(site.LoadPoints()[0])}, + } router := mux.NewRouter().StrictSlash(true) @@ -199,11 +237,26 @@ func NewHTTPd(url string, links []MenuConfig, site site, hub *SocketHub, cache * }), )) + // site api for _, r := range routes { - api. - Methods(r.Methods...). - Path(r.Pattern). - Handler(r.HandlerFunc) // routeLogger + api.Methods(r.Methods...).Path(r.Pattern).Handler(r.HandlerFunc) + } + + // loadpoint api + for id, lp := range site.LoadPoints() { + subAPI := api.PathPrefix(fmt.Sprintf("/lp/%d", id)).Subrouter() + + r := routes["getmode"] + subAPI.Methods(r.Methods...).Path(r.Pattern).Handler(CurrentChargeModeHandler(lp)) + + r = routes["setmode"] + subAPI.Methods(r.Methods...).Path(r.Pattern).Handler(ChargeModeHandler(lp)) + + r = routes["gettargetsoc"] + subAPI.Methods(r.Methods...).Path(r.Pattern).Handler(CurrentTargetSoCHandler(lp)) + + r = routes["settargetsoc"] + subAPI.Methods(r.Methods...).Path(r.Pattern).Handler(TargetSoCHandler(lp)) } srv := &http.Server{