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:
parent
80c851e423
commit
e34167ce7d
54 changed files with 1945 additions and 1499 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>—</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>—</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>—</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>—</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>—</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>—</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>—</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>—</span>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 text-primary h5">
|
||||
<span v-if="state.gridMeter">✓</span>
|
||||
<span v-else>—</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>—</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>—</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>—</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>—</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>
|
||||
|
||||
|
|
|
|||
196
assets/js/app.js
196
assets/js/app.js
|
|
@ -8,57 +8,98 @@ const loc = window.location.href.indexOf("http://localhost/evcc/assets/") === 0
|
|||
axios.defaults.baseURL = loc.protocol + "//" + loc.hostname + (loc.port ? ":" + loc.port : "") + "/api";
|
||||
axios.defaults.headers.post['Content-Type'] = 'application/json';
|
||||
|
||||
//
|
||||
// Mixins
|
||||
//
|
||||
|
||||
let formatter = {
|
||||
data: function () {
|
||||
return {
|
||||
fmtLimit: 100,
|
||||
fmtDigits: 1,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
round: function(num, precision) {
|
||||
var base = 10 ** precision;
|
||||
return (Math.round(num * base) / base).toFixed(precision);
|
||||
},
|
||||
fmt: function (val) {
|
||||
if (val === undefined || val === null) {
|
||||
return 0;
|
||||
}
|
||||
val = Math.abs(val);
|
||||
return val >= this.fmtLimit ? this.round(val / 1e3, this.fmtDigits) : this.round(val, 0);
|
||||
},
|
||||
fmtUnit: function (val) {
|
||||
return Math.abs(val) >= this.fmtLimit ? "k" : "";
|
||||
},
|
||||
fmtDuration: function (d) {
|
||||
if (d <= 0 || d == null) {
|
||||
return '—';
|
||||
}
|
||||
var seconds = "0" + (d % 60);
|
||||
var minutes = "0" + (Math.floor(d / 60) % 60);
|
||||
var hours = "" + Math.floor(d / 3600);
|
||||
if (hours.length < 2) {
|
||||
hours = "0" + hours;
|
||||
}
|
||||
return hours + ":" + minutes.substr(-2) + ":" + seconds.substr(-2);
|
||||
},
|
||||
fmtShortDuration: function (d) {
|
||||
if (d <= 0 || d == null) {
|
||||
return '—';
|
||||
}
|
||||
var minutes = (Math.floor(d / 60) % 60);
|
||||
var hours = Math.floor(d / 3600);
|
||||
var tm;
|
||||
if (hours >= 1) {
|
||||
minutes = "0" + minutes;
|
||||
tm = hours + ":" + minutes.substr(-2) + "h";
|
||||
} else {
|
||||
var seconds = "0" + (d % 60);
|
||||
tm = minutes + ":" + seconds.substr(-2) + "m";
|
||||
}
|
||||
return tm;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// State
|
||||
//
|
||||
|
||||
let store = {
|
||||
initialized: false,
|
||||
state: {
|
||||
// configuration
|
||||
mode: null,
|
||||
soc: null,
|
||||
socCapacity: null,
|
||||
socTitle: null,
|
||||
gridMeter: true,
|
||||
pvMeter: true,
|
||||
chargeMeter: true,
|
||||
phases: null,
|
||||
minCurrent: null,
|
||||
maxCurrent: null,
|
||||
// runtime
|
||||
connected: null,
|
||||
charging: null,
|
||||
gridPower: null,
|
||||
pvPower: null,
|
||||
chargePower: null,
|
||||
chargeDuration: null,
|
||||
chargeEstimate: -1,
|
||||
chargedEnergy: null,
|
||||
socCharge: "—"
|
||||
loadpoints: [],
|
||||
},
|
||||
update: function(msg, force) {
|
||||
Object.keys(msg).forEach(function(k) {
|
||||
if (force || this[k] !== undefined) {
|
||||
this[k] = msg[k];
|
||||
} else {
|
||||
if (k == "error") {
|
||||
toasts.error({message: msg[k]});
|
||||
} else if (k == "warn") {
|
||||
toasts.warn({message: msg[k]});
|
||||
} else {
|
||||
console.log("invalid key: " + k);
|
||||
}
|
||||
update: function(msg) {
|
||||
let target = this.state;
|
||||
if (msg.loadpoint !== undefined) {
|
||||
while (this.state.loadpoints.length <= msg.loadpoint) {
|
||||
this.state.loadpoints.push({});
|
||||
}
|
||||
}, this.state);
|
||||
target = this.state.loadpoints[msg.loadpoint];
|
||||
}
|
||||
|
||||
Object.keys(msg).forEach(function (k) {
|
||||
if (typeof toasts[k] === "function") {
|
||||
toasts[k]({message: msg[k]})
|
||||
} else {
|
||||
Vue.set(target, k, msg[k]);
|
||||
}
|
||||
});
|
||||
},
|
||||
init: function() {
|
||||
if (!store.initialized) {
|
||||
axios.get("config").then(function(response) {
|
||||
store.update(response.data);
|
||||
store.initialized = true;
|
||||
}).catch(toasts.error);
|
||||
}
|
||||
axios.get("config").then(function(msg) {
|
||||
for (let i=0; i<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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ type config struct {
|
|||
Meters []qualifiedConfig
|
||||
Chargers []qualifiedConfig
|
||||
Vehicles []qualifiedConfig
|
||||
Site map[string]interface{}
|
||||
LoadPoints []map[string]interface{}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ func TestDistConfig(t *testing.T) {
|
|||
}
|
||||
|
||||
// check config is valid
|
||||
loadConfig(conf, nil)
|
||||
_ = loadConfig(conf)
|
||||
}
|
||||
|
||||
func TestMeterConfig(t *testing.T) {
|
||||
|
|
|
|||
24
cmd/root.go
24
cmd/root.go
|
|
@ -141,31 +141,29 @@ func run(cmd *cobra.Command, args []string) {
|
|||
uri := viper.GetString("uri")
|
||||
log.INFO.Println("listening at", uri)
|
||||
|
||||
// setup messaging
|
||||
notificationChan := configureMessengers(conf.Messaging)
|
||||
|
||||
// setup mqtt
|
||||
if viper.Get("mqtt") != nil {
|
||||
provider.MQTT = provider.NewMqttClient(conf.Mqtt.Broker, conf.Mqtt.User, conf.Mqtt.Password, clientID(), 1)
|
||||
}
|
||||
|
||||
// setup loadpoints
|
||||
loadPoints := loadConfig(conf, notificationChan)
|
||||
site := loadConfig(conf)
|
||||
|
||||
// start broadcasting values
|
||||
tee := &Tee{}
|
||||
|
||||
// value cache
|
||||
cache := util.NewCache()
|
||||
go cache.Run(tee.Attach())
|
||||
|
||||
// setup database
|
||||
if conf.Influx.URL != "" {
|
||||
configureDatabase(tee.Attach(), conf.Influx)
|
||||
configureDatabase(conf.Influx, site.LoadPoints(), tee.Attach())
|
||||
}
|
||||
|
||||
// create webserver
|
||||
socketHub := server.NewSocketHub()
|
||||
httpd := server.NewHTTPd(uri, conf.Menu, loadPoints[0], socketHub)
|
||||
httpd := server.NewHTTPd(uri, conf.Menu, site, socketHub, cache)
|
||||
|
||||
// publish to UI
|
||||
go socketHub.Run(tee.Attach(), cache)
|
||||
|
|
@ -177,12 +175,14 @@ func run(cmd *cobra.Command, args []string) {
|
|||
// capture log messages for UI
|
||||
util.CaptureLogs(valueChan)
|
||||
|
||||
// start all loadpoints
|
||||
for _, lp := range loadPoints {
|
||||
lp.Dump()
|
||||
lp.Prepare(valueChan, notificationChan)
|
||||
go lp.Run(conf.Interval)
|
||||
}
|
||||
// setup messaging
|
||||
pushChan := configureMessengers(conf.Messaging, cache)
|
||||
|
||||
// set channels
|
||||
site.Prepare(valueChan, pushChan)
|
||||
|
||||
site.DumpConfig()
|
||||
go site.Run(conf.Interval)
|
||||
|
||||
log.FATAL.Println(httpd.ListenAndServe())
|
||||
}
|
||||
|
|
|
|||
25
cmd/setup.go
25
cmd/setup.go
|
|
@ -3,6 +3,7 @@ package cmd
|
|||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/andig/evcc/core"
|
||||
|
|
@ -17,7 +18,7 @@ func init() {
|
|||
}
|
||||
|
||||
// setup influx databases
|
||||
func configureDatabase(in <-chan util.Param, conf server.InfluxConfig) {
|
||||
func configureDatabase(conf server.InfluxConfig, loadPoints []*core.LoadPoint, in <-chan util.Param) {
|
||||
influx := server.NewInfluxClient(
|
||||
conf.URL,
|
||||
conf.Token,
|
||||
|
|
@ -35,12 +36,12 @@ func configureDatabase(in <-chan util.Param, conf server.InfluxConfig) {
|
|||
limiter := server.NewLimiter(5 * time.Second)
|
||||
in = limiter.Pipe(in)
|
||||
|
||||
go influx.Run(in)
|
||||
go influx.Run(loadPoints, in)
|
||||
}
|
||||
|
||||
func configureMessengers(conf messagingConfig) chan push.Event {
|
||||
func configureMessengers(conf messagingConfig, cache *util.Cache) chan push.Event {
|
||||
notificationChan := make(chan push.Event, 1)
|
||||
notificationHub := push.NewHub(conf.Events)
|
||||
notificationHub := push.NewHub(conf.Events, cache)
|
||||
|
||||
for _, service := range conf.Services {
|
||||
impl := push.NewMessengerFromConfig(service.Type, service.Other)
|
||||
|
|
@ -57,10 +58,21 @@ func clientID() string {
|
|||
return fmt.Sprintf("evcc-%d", pid)
|
||||
}
|
||||
|
||||
func loadConfig(conf config, eventsChan chan push.Event) (loadPoints []*core.LoadPoint) {
|
||||
func loadConfig(conf config) *core.Site {
|
||||
cp := &ConfigProvider{}
|
||||
cp.configure(conf)
|
||||
|
||||
loadPoints := configureLoadPoints(conf, cp)
|
||||
site := configureSite(conf.Site, cp, loadPoints)
|
||||
|
||||
return site
|
||||
}
|
||||
|
||||
func configureSite(conf map[string]interface{}, cp *ConfigProvider, loadPoints []*core.LoadPoint) *core.Site {
|
||||
return core.NewSiteFromConfig(log, cp, conf, loadPoints)
|
||||
}
|
||||
|
||||
func configureLoadPoints(conf config, cp *ConfigProvider) (loadPoints []*core.LoadPoint) {
|
||||
// slice of loadpoints
|
||||
lps, ok := viper.AllSettings()["loadpoints"]
|
||||
if !ok {
|
||||
|
|
@ -71,7 +83,8 @@ func loadConfig(conf config, eventsChan chan push.Event) (loadPoints []*core.Loa
|
|||
var lpc []map[string]interface{}
|
||||
util.DecodeOther(log, lps, &lpc)
|
||||
|
||||
for _, lpc := range lpc {
|
||||
for id, lpc := range lpc {
|
||||
log := util.NewLogger("lp-" + strconv.Itoa(id+1))
|
||||
lp := core.NewLoadPointFromConfig(log, cp, lpc)
|
||||
loadPoints = append(loadPoints, lp)
|
||||
}
|
||||
|
|
|
|||
164
core/api.go
164
core/api.go
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
10
core/config.go
Normal 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
30
core/helper.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
package core
|
||||
|
||||
import "github.com/andig/evcc/util"
|
||||
|
||||
var log = util.NewLogger("load")
|
||||
369
core/site.go
Normal file
369
core/site.go
Normal 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
60
core/site_test.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
package wrapper
|
||||
|
||||
import "github.com/andig/evcc/util"
|
||||
|
||||
var log = util.NewLogger("wrap")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
120
mock/mock_chargerhandler.go
Normal 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
48
mock/mock_loadpoint.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}{}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}{}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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{}{
|
||||
|
|
|
|||
38
push/hub.go
38
push/hub.go
|
|
@ -1,28 +1,27 @@
|
|||
package push
|
||||
|
||||
import "github.com/andig/evcc/util"
|
||||
import (
|
||||
"github.com/andig/evcc/util"
|
||||
)
|
||||
|
||||
// Event is a notification event
|
||||
type Event struct {
|
||||
Event string
|
||||
Attributes map[string]interface{}
|
||||
}
|
||||
|
||||
// apply applies the event template to the content to produce the actual message
|
||||
func (e Event) apply(template string) (string, error) {
|
||||
return util.ReplaceFormatted(template, e.Attributes)
|
||||
LoadPoint *int // optional loadpoint id
|
||||
Event string
|
||||
}
|
||||
|
||||
// Hub subscribes to event notifications and sends them to client devices
|
||||
type Hub struct {
|
||||
definitions map[string]EventTemplate
|
||||
sender []Sender
|
||||
cache *util.Cache
|
||||
}
|
||||
|
||||
// NewHub creates push hub with definitions and receiver
|
||||
func NewHub(definitions map[string]EventTemplate) *Hub {
|
||||
func NewHub(definitions map[string]EventTemplate, cache *util.Cache) *Hub {
|
||||
h := &Hub{
|
||||
definitions: definitions,
|
||||
cache: cache,
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
|
@ -32,6 +31,20 @@ func (h *Hub) Add(sender Sender) {
|
|||
h.sender = append(h.sender, sender)
|
||||
}
|
||||
|
||||
// apply applies the event template to the content to produce the actual message
|
||||
func (h *Hub) apply(ev Event, template string) (string, error) {
|
||||
attr := make(map[string]interface{})
|
||||
|
||||
// get all values from cache
|
||||
for _, p := range h.cache.All() {
|
||||
if p.LoadPoint == nil || ev.LoadPoint == p.LoadPoint {
|
||||
attr[p.Key] = p.Val
|
||||
}
|
||||
}
|
||||
|
||||
return util.ReplaceFormatted(template, attr)
|
||||
}
|
||||
|
||||
// Run is the Hub's main publishing loop
|
||||
func (h *Hub) Run(events <-chan Event) {
|
||||
for ev := range events {
|
||||
|
|
@ -42,12 +55,13 @@ func (h *Hub) Run(events <-chan Event) {
|
|||
definition, ok := h.definitions[ev.Event]
|
||||
if !ok {
|
||||
log.ERROR.Printf("invalid event %v", ev.Event)
|
||||
break
|
||||
continue
|
||||
}
|
||||
|
||||
msg, err := ev.apply(definition.Msg)
|
||||
msg, err := h.apply(ev, definition.Msg)
|
||||
if err != nil {
|
||||
log.ERROR.Printf("invalid message template: %v", err)
|
||||
log.ERROR.Printf("invalid template for %s: %v", ev.Event, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, sender := range h.sender {
|
||||
|
|
|
|||
178
server/assets.go
178
server/assets.go
|
|
@ -3561,102 +3561,108 @@ oJE6K0E084u3ea7ne0X73qsc2P2KOB+ngKecxITsMAgTVJfqMwAA//+21HeD5gEAAA==
|
|||
"/index.html": {
|
||||
name: "index.html",
|
||||
local: "../assets/index.html",
|
||||
size: 13184,
|
||||
size: 13271,
|
||||
modtime: 1566640112,
|
||||
compressed: `
|
||||
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
|
||||
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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
|
||||
`,
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{}{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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\""},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ func TestReplaceNoMatch(t *testing.T) {
|
|||
"bar": "baz",
|
||||
})
|
||||
|
||||
if s != "" || err == nil {
|
||||
if err == nil {
|
||||
t.Error(s, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import (
|
|||
type Nissan struct {
|
||||
*embed
|
||||
session *carwings.Session
|
||||
chargeStateG provider.FloatGetter
|
||||
chargeStateG func() (float64, error)
|
||||
}
|
||||
|
||||
// NewNissanFromConfig creates a new vehicle
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue