Implement multiple loadpoints and new UI (#199)

* Support multiple loadpoints

* Finalize loadpoint site split

* Simplify global voltage

* Fix push template error stopping main process

* Show only last error on retry

* Improve influx v1 compatibility

* Update push templates

* Revert influx v1 user change
This commit is contained in:
andig 2020-07-16 09:34:58 +02:00 • committed by GitHub
parent 80c851e423
commit e34167ce7d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 1945 additions and 1499 deletions

View file

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

View file

@ -35,6 +35,13 @@
<!-- VueJS -->
<script src="js/vue.min.js"></script>
<script src="js/vue-router.min.js"></script>
<style>
/* reverse loading animation */
.progress-bar-animated {
animation-direction: reverse;
}
</style>
</head>
<body>
@ -84,7 +91,7 @@
</div>
<script type="text/x-template" id="message-template">
<div class="toast" data-delay="10000" v-bind:data-autohide="item.type == 'warn' ? 'true' : 'false'">
<div class="toast" data-delay="10000" v-bind:data-autohide="true">
<div class="toast-header">
<strong class="mr-auto" v-if="item.type != 'warn'"><i class="text-danger fas fa-exclamation-triangle"></i> Error</strong>
<strong class="mr-auto" v-if="item.type == 'warn'"><i class="text-warning fas fa-exclamation-triangle"></i> Warning</strong>
@ -99,240 +106,239 @@
<script type="text/x-template" id="main-template">
<div class="container">
<div class="pricing-header px-3 pt-3 mx-auto text-center">
<h1 class="display-4">Laden</h1>
<p class="lead">Lademodus auswählen und EV verbinden um Ladevorgang zu starten.</p>
</div>
<modeswitch></modeswitch>
<datapanel></datapanel>
<site></site>
</div>
</script>
<script type="text/x-template" id="mode-template">
<div class="pricing-header px-3 pb-3 mx-auto text-center">
<div class="btn-group btn-group-toggle py-4 mb-2">
<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</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 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="data-template">
<script type="text/x-template" id="site-template">
<div>
<div class="card-deck mb-3 text-center">
<div class="col-12" v-if="state.soc" v-bind:class="{
'col-md-6':items==2, 'col-md-4':items==3, 'col-md-6':items==4,
'col-lg-6':items==2, 'col-lg-3':items==4 }">
<div class="card mb-4 shadow-sm">
<div class="card-header">
<h4 class="my-0 font-weight-normal">Batterie</h4>
</div>
<div class="card-body">
<h2 class="card-title pricing-card-title">
{{state.socCharge}} <small class="text-muted">%</small>
</h2>
<p>Ladezustand</p>
</div>
</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>
</div>
<div class="col-12" v-bind:class="{
'col-md-6':items==2, 'col-md-4':items==3, 'col-md-6':items==4,
'col-lg-6':items==2, 'col-lg-3':items==4 }">
<div class="card mb-4 shadow-sm">
<div class="card-header">
<h4 class="my-0 font-weight-normal">Wallbox</h4>
</div>
<div class="card-body">
<h2 class="card-title pricing-card-title">
{{fmt(state.chargePower)}} <small class="text-muted">{{fmtUnit(state.chargePower)}}W</small>
</h2>
<p>Ladeleistung <i class="text-primary fas fa-leaf" v-if="state.chargePower"></i></p>
</div>
</div>
<div class="row">
<div class="d-none d-md-block col-md-4 my-4">
</div>
<div class="col-6 col-md-2 my-4" 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>
Einspeisung <i class="text-primary fas fa-arrow-up"></i>
</div>
<div class="col-12" v-if="state.gridMeter" v-bind:class="{
'col-md-6':items==2, 'col-md-4':items==3, 'col-md-6':items==4,
'col-lg-6':items==2, 'col-lg-3':items==4 }">
<div class="card mb-4 shadow-sm">
<div class="card-header">
<h4 class="my-0 font-weight-normal">Netz</h4>
</div>
<div class="card-body">
<h2 class="card-title pricing-card-title">{{fmt(state.gridPower)}} <small
class="text-muted">{{fmtUnit(state.gridPower)}}W</small></h2>
<p v-if="state.gridPower > 0">
Bezug <i class="text-primary fas fa-arrow-down"></i>
</p>
<p v-else>
Einspeisung <i class="text-primary fas fa-arrow-up"></i>
</p>
</div>
</div>
<h2>
{{fmt(state.gridPower)}} <small class="text-muted">{{fmtUnit(state.gridPower)}}W</small>
</h2>
</div>
<div class="col-6 col-md-2 my-4" v-if="state.pvMeter">
<div class="mb-2">Erzeugung <i class="text-primary fas fas fa-sun" v-if="state.pvPower < 0"></i></div>
<h2>
{{fmt(state.pvPower)}} <small class="text-muted">{{fmtUnit(state.pvPower)}}W</small>
</h2>
</div>
<div class="col-6 col-md-2 my-4" 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>
<h2>
{{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 mb-4 bg-light"
v-bind:class="{ 'd-md-none': state.loadpoints.length>1 }">
<modeswitch></modeswitch>
</div>
</div>
<div class="col-12" v-if="state.pvMeter" v-bind:class="{
'col-md-6':items==2, 'col-md-4':items==3, 'col-md-6':items==4,
'col-lg-6':items==2, 'col-lg-3':items==4 }">
<div class="card mb-4 shadow-sm">
<div class="card-header">
<h4 class="my-0 font-weight-normal">PV</h4>
</div>
<div class="card-body">
<h2 class="card-title pricing-card-title">
{{fmt(state.pvPower)}} <small class="text-muted">{{fmtUnit(state.pvPower)}}W</small>
</h2>
<p>Erzeugung <i class="text-primary fas fas fa-sun" v-if="false"></i></p>
</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 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 my-4">
<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>
<div class="row">
<div class="col-12 col-md-6 col-lg-3">
<div class="row py-2">
<div class="col-6 text-muted text-right">
Fahrzeug:
</div>
<div class="col-6 text-primary h5">
<span v-if="state.connected">verbunden</span><span v-else>&mdash;</span>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="row py-2">
<div class="col-6 text-muted text-right">
Geladen:
</div>
<div class="col-6 text-primary h5">
{{fmt(state.chargedEnergy)}} <small>{{fmtUnit(state.chargedEnergy)}}Wh</small>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="row py-2">
<div class="col-6 text-muted text-right">
Ladedauer:
</div>
<div class="col-6 text-primary h5">
{{fmtDuration(state.chargeDuration)}}
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="row py-2">
<div class="col-6 text-muted text-right">
Restladezeit:
</div>
<div class="col-6 text-primary h5">
{{fmtDuration(state.chargeEstimate)}}
</div>
</div>
</div>
<div class="col-6 col-md-2 my-4">
<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 my-4">
<div class="mb-2">Energie</div>
<h2>
{{fmt(state.chargedEnergy)}} <small class="text-muted">{{fmtUnit(state.chargedEnergy)}}Wh</small>
</h2>
</div>
<div class="col-6 col-md-2 my-4">
<div class="mb-2">Dauer</div>
<h2>
{{fmtShortDuration(state.chargeDuration)}}
</h2>
</div>
<div class="col-6 col-md-2 my-4">
<div class="mb-2">Restzeit</div>
<h2>
{{fmtShortDuration(state.chargeEstimate)}}
</h2>
</div>
</div>
</div>
</script>
<script type="text/x-template" id="config-template">
<div class="container">
<h1 class="display-4 pt-3 mx-auto text-center">Konfiguration</h1>
<p class="lead mx-auto text-center">Details der Fahrzeug-, Wallbox- und Zählerkonfiguration.</p>
<!-- <h1 class="display-4 pt-3 mx-auto text-center">Konfiguration</h1>
<p class="lead mx-auto text-center">Details der Fahrzeug-, Wallbox- und Zählerkonfiguration.</p> -->
<h5 class="pb-2 pt-4 mx-auto text-center">Fahrzeug</h5>
<div class="row py-2">
<div class="col-6 col-md-3 text-right">
Modell:
</div>
<div class="col-6 col-md-3 text-primary h5">
<span v-if="state.socTitle">{{state.socTitle}}</span>
<span v-else>&mdash;</span>
</div>
<div class="col-6 col-md-3 text-right">
Kapazität:
</div>
<div class="col-6 col-md-3 text-primary h5">
<span v-if="state.socCapacity">{{state.socCapacity}} kWh</span>
<span v-else>&mdash;</span>
<div class="row mt-4 border-bottom">
<div class="col-12">
<p class="h1">{{state.title||"Home"}}</p>
</div>
</div>
<h5 class="pb-2 pt-4 mx-auto text-center">Wallbox</h5>
<div class="row py-2">
<div class="col-6 col-md-3 text-right">
Phasen:
</div>
<div class="col-6 col-md-3 text-primary h5">
<span v-if="state.phases">{{state.phases}}p</span>
<span v-else>&mdash;</span>
</div>
<div class="col-6 col-md-3 text-right">
Phasenumschaltung:
</div>
<div class="col-6 col-md-3 text-primary h5">
<span v-if="state.phases">nicht implementiert</span>
</div>
<div class="col-6 col-md-3 text-right">
Min. Ladestrom:
</div>
<div class="col-6 col-md-3 text-primary h5">
<span v-if="state.minCurrent !== undefined">{{state.minCurrent}} A</span>
<span v-else>&mdash;</span>
</div>
<div class="col-6 col-md-3 text-right">
Max. Ladestrom:
</div>
<div class="col-6 col-md-3 text-primary h5">
<span v-if="state.maxCurrent !== undefined">{{state.maxCurrent}} A</span>
<span v-else>&mdash;</span>
</div>
</div>
<h5 class="pb-2 pt-4 mx-auto text-center">Zähler</h5>
<div class="row py-2">
<div class="col-6 col-md-3 text-right">
PV Zähler:
</div>
<div class="col-6 col-md-3 text-primary h5">
<span v-if="state.pvMeter">✓</span>
<span v-else>&mdash;</span>
</div>
<div class="col-6 col-md-3 text-right">
<div class="row h5">
<div class="col-md-4"></div>
<div class="col-6 col-md-2 py-3">
Netzzähler:
<span class="text-primary" v-if="state.gridMeter">✓</span>
<span class="text-primary" v-else>&mdash;</span>
</div>
<div class="col-6 col-md-3 text-primary h5">
<span v-if="state.gridMeter">✓</span>
<span v-else>&mdash;</span>
<div class="col-6 col-md-2 py-3">
PV Zähler:
<span class="text-primary" v-if="state.pvMeter">✓</span>
<span class="text-primary" v-else>&mdash;</span>
</div>
<div class="col-6 col-md-3 text-right">
Ladezähler:
</div>
<div class="col-6 col-md-3 text-primary h5">
<span v-if="state.chargeMeter">✓</span>
<span v-else>&mdash;</span>
<div class="col-6 col-md-2 py-3">
Batteriezähler:
<span class="text-primary" v-if="state.batteryMeter">✓</span>
<span class="text-primary" v-else>&mdash;</span>
</div>
</div>
<div v-for="(loadpoint,id) in state.loadpoints" v-bind:loadpoint="loadpoint" :id="'loadpoint-'+id">
<div class="row mt-4 border-bottom">
<div class="col-12">
<p class="h1">{{loadpoint.title||"Ladepunkt"}}</p>
</div>
</div>
<div class="row h5">
<div class="col-md-4"></div>
<div class="col-6 col-md-2 py-3">
Ladezähler:
<span class="text-primary" v-if="loadpoint.chargeMeter">✓</span>
<span class="text-primary" v-else>&mdash;</span>
</div>
<div class="col-6 col-md-2 py-3">
Phasen:
<span class="text-primary">{{loadpoint.phases}}p</span>
</div>
<div class="col-6 col-md-2 py-3">
Min. Strom:
<span class="text-primary">{{loadpoint.minCurrent}}A</span>
</div>
<div class="col-6 col-md-2 py-3">
Max. Strom:
<span class="text-primary">{{loadpoint.maxCurrent}}A</span>
</div>
</div>
<div class="row h5">
<div class="col-md-4"></div>
<div class="col-md-8 h2">
<div class="row py-3 h2 border-bottom">
<div class="col-12">
Fahrzeug
</div>
</div>
<div class="row h5">
<div class="col-6 py-3">
Modell:
<span class="text-primary">{{loadpoint.socTitle||"—"}}</span>
</div>
<div class="col-6 py-3">
Kapazität:
<span class="text-primary">{{loadpoint.socCapacity}}kWh</span>
</div>
</div>
</div>
</div>
</div>
</div>
</script>

View file

@ -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<msg.data.loadpoints.length; i++) {
let data = Object.assign(msg.data.loadpoints[i], { loadpoint: i });
this.update(data);
}
delete msg.data.loadpoints;
this.update(msg.data);
}.bind(this)).catch(toasts.error);
}
};
@ -119,7 +160,7 @@ Vue.component('message-toast', {
$(id).toast('show');
$(id).on('hidden.bs.toast', function () {
toasts.remove(this.item);
})
}.bind(this))
},
});
@ -138,62 +179,21 @@ Vue.component('modeswitch', {
set: function(mode) {
axios.post('mode/' + mode).then(function(response) {
this.state.mode = response.data.mode;
}.bind(this)).catch(error.raise);
}.bind(this)).catch(toasts.error);
}
}
},
});
Vue.component("datapanel", {
template: "#data-template",
Vue.component("site", {
template: "#site-template",
mixins: [formatter],
data: function() {
return {
tickerHandle: null,
state: store.state // global state
};
},
computed: {
items: function() {
if (this.state.soc && this.state.pvMeter) {
return 4;
} else if (this.state.soc || this.state.pvMeter) {
return 3;
} else {
return 2;
}
}
},
watch: {
"state.chargeDuration": function() {
window.clearInterval(this.tickerHandle);
if (this.state.charging) {
// only ticker if actually charging
this.tickerHandle = window.setInterval(function() {
this.state.chargeDuration += 1;
}.bind(this), 1000);
}
},
},
methods: {
fmt: function(val) {
val = Math.abs(val);
return val >= 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);

View file

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

View file

@ -22,6 +22,7 @@ type config struct {
Meters []qualifiedConfig
Chargers []qualifiedConfig
Vehicles []qualifiedConfig
Site map[string]interface{}
LoadPoints []map[string]interface{}
}

View file

@ -42,7 +42,7 @@ func TestDistConfig(t *testing.T) {
}
// check config is valid
loadConfig(conf, nil)
_ = loadConfig(conf)
}
func TestMeterConfig(t *testing.T) {

View file

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

View file

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

View file

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

View file

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

View file

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

10
core/config.go Normal file
View file

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

30
core/helper.go Normal file
View file

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

View file

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

View file

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

View file

@ -1,5 +0,0 @@
package core
import "github.com/andig/evcc/util"
var log = util.NewLogger("load")

369
core/site.go Normal file
View file

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

60
core/site_test.go Normal file
View file

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

View file

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

View file

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

View file

@ -1,5 +0,0 @@
package wrapper
import "github.com/andig/evcc/util"
var log = util.NewLogger("wrap")

View file

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

View file

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

View file

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

120
mock/mock_chargerhandler.go Normal file
View file

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

48
mock/mock_loadpoint.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3561,102 +3561,108 @@ oJE6K0E084u3ea7ne0X73qsc2P2KOB+ngKecxITsMAgTVJfqMwAA//+21HeD5gEAAA==
"/index.html": {
name: "index.html",
local: "../assets/index.html",
size: 13184,
size: 13271,
modtime: 1566640112,
compressed: `
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
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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
`,
},

View file

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

View file

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

View file

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

View file

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

View file

@ -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\""},
}

View file

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

View file

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

View file

@ -65,7 +65,7 @@ func TestReplaceNoMatch(t *testing.T) {
"bar": "baz",
})
if s != "" || err == nil {
if err == nil {
t.Error(s, err)
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@ import (
type Nissan struct {
*embed
session *carwings.Session
chargeStateG provider.FloatGetter
chargeStateG func() (float64, error)
}
// NewNissanFromConfig creates a new vehicle

View file

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

View file

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

View file

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

View file

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