Implement per-loadpoint mode and target soc setting (#240)

This commit is contained in:
andig 2020-07-25 12:10:52 +02:00
parent 66aa4838a7
commit 880d39e07e
10 changed files with 578 additions and 316 deletions

View file

@ -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)

View file

@ -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;
}
</style>
</head>
<body>
@ -128,66 +135,52 @@
</div>
</script>
<script type="text/x-template" id="mode-template">
<div class="row">
<div class="col-12">
<div class="btn-group btn-group-toggle bg-white">
<label class="btn btn-outline-primary shadow-none" v-bind:class="{active:mode=='off'}">
<input type="radio" value="off" v-on:click="mode='off'">Stop
</label>
<label class="btn btn-outline-primary shadow-none" v-bind:class="{active:mode=='now'}">
<input type="radio" value="now" v-on:click="mode='now'">
<span class="d-inline d-sm-none">Sofort</span>
<span class="d-none d-sm-inline">Sofortladen</span>
</label>
<label class="btn btn-outline-primary shadow-none" v-bind:class="{active:mode=='minpv'}" v-if="state.gridMeter">
<input type="radio" value="minpv" v-on:click="mode='minpv'">
<span class="d-inline d-sm-none">Min + PV</span>
<span class="d-none d-sm-inline">Minimum + PV</span>
</label>
<label class="btn btn-outline-primary shadow-none" v-bind:class="{active:mode=='pv'}" v-if="state.gridMeter">
<input type="radio" value="pv" v-on:click="mode='pv'">
<span class="d-inline d-sm-none">PV</span>
<span class="d-none d-sm-inline">Nur PV</span>
</label>
</div>
</div>
</div>
</script>
<script type="text/x-template" id="site-template">
<div>
<div class="row">
<div class="d-none d-md-flex col-12 col-md-4 mt-md-4 align-items-end">
<p class="h1">{{state.title||"Home"}}</p>
</div>
<div class="col-12 col-md-8 mt-md-4" v-if="multi">
<site-details v-bind:state="state"></site-details>
</div>
</div>
<div class="row mt-4 border-bottom">
<div class="col-12 col-md-4">
<p class="h1">{{state.title||"Home"}}</p>
</div>
<div class="d-none col-12 col-md-8 pt-1"
v-bind:class="{'d-md-block': state.loadpoints.length>1}">
<modeswitch></modeswitch>
<div class="row d-none d-md-flex border-bottom"></div>
<div class="row" v-if="!multi">
<div class="d-none d-md-inline-block col-md-4"></div>
<div class="col-12 col-md-8">
<site-details v-bind:state="state"></site-details>
</div>
</div>
<loadpoint v-for="(loadpoint,id) in state.loadpoints"
v-bind:id="id" :state="loadpoint" :pv="state.gridMeter" :multi="multi">
</loadpoint>
</div>
</script>
<script type="text/x-template" id="site-details-template">
<div class="row">
<div class="d-none d-md-block col-md-4">
</div>
<div class="col-6 col-md-2 mt-3" v-if="state.gridMeter">
<div class="col-6 col-md-3 mt-3" v-if="state.gridMeter">
<div class="mb-2" v-if="state.gridPower > 0">
Bezug <i class="text-primary fas fa-arrow-down"></i>
</div><div class="mb-2" v-else>
</div>
<div class="mb-2" v-else>
Einspeisung <i class="text-primary fas fa-arrow-up"></i>
</div>
<h2>
{{fmt(state.gridPower)}} <small class="text-muted">{{fmtUnit(state.gridPower)}}W</small>
</h2>
</div>
<div class="col-6 col-md-2 mt-3" v-if="state.pvMeter">
<div class="mb-2">Erzeugung <i class="text-primary fas fas fa-sun" v-if="state.pvPower < 0"></i></div>
<div class="col-6 col-md-3 mt-3" v-if="state.pvMeter">
<div class="mb-2">Erzeugung <i class="fas fas fa-sun" v-bind:class="{'text-primary':state.pvPower<0,'text-muted':state.pvPower>=0}"></i></div>
<h2>
{{fmt(state.pvPower)}} <small class="text-muted">{{fmtUnit(state.pvPower)}}W</small>
</h2>
</div>
<div class="d-none col-md-2 mt-3" v-if="state.batteryMeter">
<div class="d-none d-md-flex col-md-3 mt-3" v-if="state.batteryMeter">
<div class="mb-2">Batterie <i class="text-primary fas"
v-bind:class="{'fa-arrow-down':state.batteryPower<0, 'fa-arrow-up':state.batteryPower>0}"></i>
</div>
@ -195,71 +188,99 @@
{{fmt(state.batteryPower)}} <small class="text-muted">{{fmtUnit(state.batteryPower)}}W</small>
</h2>
</div>
<div class="d-block col-12 text-center pt-4 pb-4 mt-3 mt-md-5 mb-0 bg-light"
v-bind:class="{ 'd-md-none': state.loadpoints.length>1 }">
<modeswitch></modeswitch>
</div>
</div>
<loadpoint v-for="(loadpoint,id) in state.loadpoints" v-bind:state="loadpoint" :id="'loadpoint-'+id">
</loadpoint>
</div>
</script>
<script type="text/x-template" id="loadpoint-template">
<div>
<div class="row mt-3 mt-md-5 border-bottom">
<div class="col-12">
<p class="h1">{{state.title||"Ladepunkt"}}</p>
</div>
</div>
<div class="row">
<div class="col-12 col-md-4 mt-3">
<div class="mb-2">{{state.socTitle||"Fahrzeug"}}</div>
<div class="progress" style="height: 24px; font-size:100%; margin-top:16px">
<div class="progress-bar" role="progressbar" v-if="!state.soc"
v-bind:class="{
'progress-bar-striped': state.charging,
'progress-bar-animated': state.charging,
'bg-primary': state.connected,
'bg-light': !(state.connected || state.charging),
'text-secondary': !(state.connected || state.charging),
}"
v-bind:style="{width: '100%'}">{{state.charging ? "laden" : (state.connected ? "verbunden" : "getrennt")}}</div>
<div class="progress-bar" role="progressbar" v-else-if="state.socCharge > 9"
v-bind:class="{
'progress-bar-striped': state.charging,
'progress-bar-animated': state.charging,
'bg-primary': !state.charging && state.connected,
}"
v-bind:style="{width: state.socCharge+'%'}">{{state.socCharge}}%</div>
<div class="progress-bar" role="progressbar" v-else
v-bind:class="{
'progress-bar-striped': state.charging,
'progress-bar-animated': state.charging,
'bg-primary': !state.charging && state.connected,
}"
v-bind:style="{width: (state.socCharge<0) ? 0 : state.socCharge+'%'}"></div>
<div class="row" v-if="multi">
<div class="col-12 col-md-4 d-md-flex mt-3 mt-md-5 align-items-end">
<span class="h1 align-bottom">{{state.title||"Ladepunkt"}}</span>
</div>
<div class="col-12 col-md-8 d-none d-md-inline-block mt-3 mt-md-5">
<loadpoint-details v-bind:state="state"></loadpoint-details>
</div>
<div class="col-12 d-md-none">
<div class="row mt-3 pb-3 bg-light">
<div class="col-12 mt-3">
<mode class="w-100" v-bind:mode="state.mode" :pv="pv" v-on:updated="targetMode"></mode>
</div>
<div class="col-12 mt-3" v-if="hasTargetSoC">
<soc class="w-100" v-bind:soc="state.targetSoC" :levels="state.socLevels" v-on:updated="targetSoC"></soc>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-2 mt-3">
<div class="row d-none d-md-flex mt-5 py-3 pb-4 text-center bg-light" v-if="!multi">
<div class="mt-3" v-bind:class="{'col-md-6':hasTargetSoC,'col-md-12':!hasTargetSoC}">
<mode v-bind:mode="state.mode" :pv="pv" :caption="true" v-on:updated="targetMode"></mode>
</div>
<div class="col-md-6 mt-3" v-if="hasTargetSoC">
<soc v-bind:soc="state.targetSoC" :levels="state.socLevels" :caption="true" v-on:updated="targetSoC"></soc>
</div>
</div>
<div class="row d-md-none mt-2 pb-3 bg-light" v-if="!multi">
<div class="col-12 mt-3">
<mode class="w-100" v-bind:mode="state.mode" :pv="pv" v-on:updated="targetMode"></mode>
</div>
<div class="col-12 mt-3" v-if="hasTargetSoC">
<soc class="w-100" v-bind:soc="state.targetSoC" :levels="state.socLevels" v-on:updated="targetSoC"></soc>
</div>
</div>
<div class="row" v-if="!multi">
<div class="col-12 col-md-4 d-none d-md-flex mt-3 mt-md-5 align-items-end">
<span class="h1 align-bottom">{{state.title||"Ladepunkt"}}</span>
</div>
</div>
<div class="row border-bottom d-none d-md-block"></div>
<div class="row">
<div class="col-12 col-md-4 mt-3 mb-3 mb-md-0">
<vehicle v-bind:state="state"></vehicle>
</div>
<div class="col-12 col-md-4 d-none d-md-inline-block mt-3" v-if="multi">
<div class="mb-2">Modus</div>
<mode class="btn-group-sm" v-bind:mode="state.mode" :pv="pv" v-on:updated="targetMode"></mode>
</div>
<div class="col-12 col-md-4 d-none d-md-inline-block mt-3" v-if="multi && hasTargetSoC">
<div class="mb-2">Ladeziel</div>
<soc class="btn-group-sm" v-bind:soc="state.targetSoC" :levels="state.socLevels" v-on:updated="targetSoC"></soc>
</div>
<div class="col-md-8 d-none d-md-inline-block" v-if="!multi">
<loadpoint-details v-bind:state="state"></loadpoint-details>
</div>
<div class="col-12 d-md-none">
<loadpoint-details v-bind:state="state"></loadpoint-details>
</div>
</div>
</div>
</script>
<script type="text/x-template" id="loadpoint-details-template">
<div class="row">
<div class="col-6 col-md-3 mt-3">
<div class="mb-2">Leistung</div>
<h2>
{{fmt(state.chargePower)}} <small class="text-muted">{{fmtUnit(state.chargePower)}}W</small>
<!-- <span class="text-muted">/</span>
{{fmt(state.chargeCurrent)}} <small class="text-muted">A</small> -->
</h2>
</div>
<div class="col-6 col-md-2 mt-3">
<div class="col-6 col-md-3 mt-3">
<div class="mb-2">Geladen</div>
<h2>
{{fmt(state.chargedEnergy)}} <small class="text-muted">{{fmtUnit(state.chargedEnergy)}}Wh</small>
</h2>
</div>
<div class="col-6 col-md-2 mt-3">
<div class="col-6 col-md-3 mt-3">
<div class="mb-2">Dauer</div>
<h2>
{{fmtShortDuration(state.chargeDuration)}} <small
@ -267,7 +288,7 @@
</h2>
</div>
<div class="col-6 col-md-2 mt-3">
<div class="col-6 col-md-3 mt-3" v-if="state.soc">
<div class="mb-2">Restzeit</div>
<h2>
{{fmtShortDuration(state.chargeEstimate)}} <small
@ -275,6 +296,67 @@
</h2>
</div>
</div>
</script>
<script type="text/x-template" id="mode-template">
<div class="btn-group btn-group-toggle bg-white shadow-none">
<label class="btn btn-outline-primary disabled caption font-weight-bold" v-if="caption">
Modus
</label>
<label class="btn btn-outline-primary" v-bind:class="{active:mode=='off',first:!caption}">
<input type="radio" value="off" v-on:click="targetMode('off')">Stop
</label>
<label class="btn btn-outline-primary" v-bind:class="{active:mode=='now'}">
<input type="radio" value="now" v-on:click="targetMode('now')">Sofort
</label>
<label class="btn btn-outline-primary" v-bind:class="{active:mode=='minpv'}" v-if="pv">
<input type="radio" value="minpv" v-on:click="targetMode('minpv')">
<span class="d-inline d-sm-none">Min + PV</span>
<span class="d-none d-sm-inline">Minimum + PV</span>
</label>
<label class="btn btn-outline-primary" v-bind:class="{active:mode=='pv'}" v-if="pv">
<input type="radio" value="pv" v-on:click="targetMode('pv')">
<span class="d-inline d-sm-none">PV</span>
<span class="d-none d-sm-inline">Nur PV</span>
</label>
</div>
</script>
<script type="text/x-template" id="soc-template">
<div class="btn-group btn-group-toggle bg-white shadow-none">
<label class="btn btn-outline-primary disabled caption font-weight-bold" v-if="caption">
Ladeziel
</label>
<label class="btn btn-outline-primary" v-for="(level,id) in levelsOrDefault" v-bind:level="level" :id="id"
:class="{active:soc==level,first:!caption&&id==0}">
<input type="radio" v-bind:value="level" v-on:click="targetSoC(level)">{{level}}%
</label>
</div>
</script>
<script type="text/x-template" id="vehicle-template">
<div>
<div class="mb-2">{{state.socTitle||"Fahrzeug"}}</div>
<div class="progress" style="height: 24px; font-size:100%; margin-top:16px">
<div class="progress-bar" role="progressbar" v-if="!state.soc" v-bind:class="{
'progress-bar-striped': state.charging,
'progress-bar-animated': state.charging,
'bg-primary': state.connected,
'bg-light': !(state.connected || state.charging),
'text-secondary': !(state.connected || state.charging),
}" v-bind:style="{width: '100%'}">{{state.charging ? "laden" : (state.connected ? "verbunden" : "getrennt")}}
</div>
<div class="progress-bar" role="progressbar" v-else-if="state.socCharge > 9" v-bind:class="{
'progress-bar-striped': state.charging,
'progress-bar-animated': state.charging,
'bg-primary': !state.charging && state.connected,
}" v-bind:style="{width: state.socCharge+'%'}">{{state.socCharge}}%</div>
<div class="progress-bar" role="progressbar" v-else v-bind:class="{
'progress-bar-striped': state.charging,
'progress-bar-animated': state.charging,
'bg-primary': !state.charging && state.connected,
}" v-bind:style="{width: (state.socCharge<0) ? 0 : state.socCharge+'%'}"></div>
</div>
</div>
</script>
@ -366,10 +448,10 @@
<div class="container">
<div class="row">
<div class="col-6">
<small class="text-muted">&copy; 2020</small>
<small class="text-muted"><<.Version>> <<if ne .Version .Commit>>(<<.Commit>>)<<end>></small>
</div>
<div class="col-6 text-right">
<small class="text-muted"><<.Version>> <<if ne .Version .Commit>>(<<.Commit>>)<<end>></small>
<small class="text-muted">&copy; 2020</small>
</div>
</div>
</div>

View file

@ -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
//

View file

@ -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

View file

@ -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()
}

View file

@ -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)
}
}
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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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
`,
},

View file

@ -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{