Refactor configuration handling (#424)

This commit is contained in:
andig 2020-11-12 11:50:22 +01:00
parent 9090514a9e
commit 7683f6637f
11 changed files with 79 additions and 153 deletions

View file

@ -447,8 +447,7 @@ EVCC provides a REST and MQTT APIs.
### REST API
- `/api/config`: EVCC static configuration
- `/api/state`: EVCC dynamic state
- `/api/state`: EVCC state (static configuration and dynamic state)
- `/api/mode`: global charge mode (writable)
- `/api/targetsoc`: global target SoC (writable)
- `/api/loadpoints/<id>/mode`: loadpoint charge mode (writable)

View file

@ -176,14 +176,14 @@ loadpoints:
</div>
<loadpoint v-for="(loadpoint,id) in state.loadpoints"
v-bind:id="id" :state="loadpoint" :pv="state.gridMeter" :multi="multi">
v-bind:id="id" :key="id" :state="loadpoint" :pv="state.gridConfigured" :multi="multi">
</loadpoint>
</div>
</script>
<script type="text/x-template" id="site-details-template">
<div class="row">
<div class="col-6 col-md-3 mt-3" v-if="state.gridMeter">
<div class="col-6 col-md-3 mt-3" v-if="state.gridConfigured">
<div class="mb-2 value" v-if="state.gridPower > 0">
Bezug <i class="text-primary fas fa-arrow-down"></i>
</div>
@ -194,7 +194,7 @@ loadpoints:
{{fmt(state.gridPower)}} <small class="text-muted">{{fmtUnit(state.gridPower)}}W</small>
</h2>
</div>
<div class="col-6 col-md-3 mt-3" v-if="state.pvMeter">
<div class="col-6 col-md-3 mt-3" v-if="state.pvConfigured">
<div class="mb-2 value">
Erzeugung <i class="fas fa-sun" v-bind:class="{'text-primary':state.pvPower<0,'text-muted':state.pvPower>=0}"></i>
</div>
@ -202,7 +202,7 @@ loadpoints:
{{fmt(state.pvPower)}} <small class="text-muted">{{fmtUnit(state.pvPower)}}W</small>
</h2>
</div>
<div class="d-md-block col-6 col-md-3 mt-3" v-bind:class="{'d-none':!state.batterySoC}" v-if="state.batteryMeter">
<div class="d-md-block col-6 col-md-3 mt-3" v-bind:class="{'d-none':!state.batterySoC}" v-if="state.batteryConfigured">
<div class="mb-2 value">Batterie
<i class="text-primary fas" v-bind:class="{'fa-arrow-down':state.batteryPower<0, 'fa-arrow-up':state.batteryPower>0}"></i>
</div>
@ -407,23 +407,22 @@ loadpoints:
<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-if="state.gridConfigured">✓</span>
<span class="text-primary" v-else>&mdash;</span>
</div>
<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-if="state.pvConfigured">✓</span>
<span class="text-primary" v-else>&mdash;</span>
</div>
<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-if="state.batteryConfigured">✓</span>
<span class="text-primary" v-else>&mdash;</span>
</div>
</div>
<div v-for="(loadpoint,id) in state.loadpoints" v-bind:loadpoint="loadpoint" :id="'loadpoint-'+id">
<div class="row mt-4 border-bottom">
<div class="col-12">
<p class="h1">{{loadpoint.title||"Ladepunkt"}}</p>
@ -434,7 +433,7 @@ loadpoints:
<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-if="loadpoint.chargeConfigured">✓</span>
<span class="text-primary" v-else>&mdash;</span>
</div>
<div class="col-6 col-md-2 py-3">

View file

@ -82,38 +82,36 @@ let formatter = {
// State
//
function setProperty(obj, props, value) {
const prop = props.shift()
if (!obj[prop]) {
Vue.set(obj, prop, {})
}
if (!props.length) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
obj[prop] = { ...obj[prop], ...value }
} else {
obj[prop] = value
}
return
}
setProperty(obj[prop], props, value)
}
let store = {
state: {
availableVersion: null,
loadpoints: [],
loadpoints: [], // ensure array type
},
update: function(msg) {
let target = this.state;
if (msg.loadpoint !== undefined) {
while (this.state.loadpoints.length <= msg.loadpoint) {
this.state.loadpoints.push({});
}
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]);
setProperty(store.state, k.split('.'), msg[k])
}
});
},
init: function() {
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);
}
};
@ -178,9 +176,32 @@ const app = new Vue({
data: {
compact: false,
},
methods: {
connect: function() {
const protocol = loc.protocol == "https:" ? "wss:" : "ws:";
const uri = protocol + "//" + loc.hostname + (loc.port ? ":" + loc.port : "") + loc.pathname + "ws";
const ws = new WebSocket(uri), self = this;
ws.onerror = function(evt) {
ws.close();
};
ws.onclose = function(evt) {
window.setTimeout(self.connect, 1000);
};
ws.onmessage = function(evt) {
try {
var msg = JSON.parse(evt.data);
store.update(msg);
}
catch (e) {
toasts.error(e, evt.data)
}
};
},
},
created: function () {
const urlParams = new URLSearchParams(window.location.search);
this.compact = urlParams.get("compact");
this.connect(); // websocket listener
},
});
@ -265,33 +286,9 @@ Vue.component('site', {
mixins: [formatter],
computed: {
multi: function() {
console.log(this.state);
return this.state.loadpoints.length > 1 || app.compact;
},
},
methods: {
connect: function() {
const protocol = loc.protocol == "https:" ? "wss:" : "ws:";
const uri = protocol + "//" + loc.hostname + (loc.port ? ":" + loc.port : "") + loc.pathname + "ws";
const ws = new WebSocket(uri), self = this;
ws.onerror = function(evt) {
ws.close();
};
ws.onclose = function(evt) {
window.setTimeout(self.connect, 1000);
};
ws.onmessage = function(evt) {
try {
var msg = JSON.parse(evt.data);
store.update(msg);
}
catch (e) {
toasts.error(e, evt.data)
}
};
},
},
created: function() {
this.connect();
}
});
@ -424,5 +421,3 @@ Vue.component("soc", {
}
},
});
store.init();

View file

@ -352,6 +352,7 @@ func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
lp.publish("mode", lp.Mode)
lp.publish("targetSoC", lp.SoC.Target)
lp.publish("minSoC", lp.SoC.Min)
lp.publish("socLevels", lp.SoC.Levels)
lp.Unlock()
// prepare charger status

View file

@ -101,63 +101,6 @@ func NewSite() *Site {
return lp
}
// SiteConfiguration contains the global site configuration
type SiteConfiguration struct {
Title string `json:"title"`
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 {
Mode string `json:"mode"`
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"`
SoCLevels []int `json:"socLevels"`
TargetSoC int `json:"targetSoC"`
}
// Configuration returns meter configuration
func (site *Site) Configuration() SiteConfiguration {
c := SiteConfiguration{
Title: site.Title,
GridMeter: site.gridMeter != nil,
PVMeter: site.pvMeter != nil,
BatteryMeter: site.batteryMeter != nil,
}
for _, lp := range site.loadpoints {
lpc := LoadpointConfiguration{
Mode: string(lp.GetMode()),
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()
lpc.SoCLevels = lp.SoC.Levels
lpc.TargetSoC = lp.GetTargetSoC()
}
c.LoadPoints = append(c.LoadPoints, lpc)
}
return c
}
func meterCapabilities(name string, meter interface{}) string {
_, power := meter.(api.Meter)
_, energy := meter.(api.MeterEnergy)

View file

@ -5,7 +5,6 @@ import "github.com/andig/evcc/api"
// SiteAPI is the external site API
type SiteAPI interface {
Healthy() bool
Configuration() SiteConfiguration
LoadPoints() []LoadPointAPI
LoadPointSettingsAPI
}

View file

@ -49,7 +49,6 @@ type SEMP struct {
// site is the minimal interface for accessing site methods
type site interface {
Configuration() core.SiteConfiguration
LoadPoints() []core.LoadPointAPI
}

View file

@ -67,7 +67,6 @@ func indexHandler(site core.SiteAPI, useLocal bool) http.HandlerFunc {
"Version": Version,
"Commit": Commit,
"Configured": len(site.LoadPoints()),
"Tag": time.Now().Unix(),
}); err != nil {
log.ERROR.Println("httpd: failed to render main page:", err.Error())
}
@ -102,14 +101,6 @@ func HealthHandler(site core.SiteAPI) http.HandlerFunc {
}
}
// ConfigHandler returns current charge mode
func ConfigHandler(site core.SiteAPI) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
res := site.Configuration()
jsonResponse(w, r, res)
}
}
// TemplatesHandler returns current charge mode
func TemplatesHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@ -259,9 +250,8 @@ type HTTPd struct {
func NewHTTPd(url string, site core.SiteAPI, hub *SocketHub, cache *util.Cache) *HTTPd {
var routes = map[string]route{
"health": {[]string{"GET"}, "/health", HealthHandler(site)},
"config": {[]string{"GET"}, "/config", ConfigHandler(site)},
"templates": {[]string{"GET"}, "/config/templates/{class:[a-z]+}", TemplatesHandler()},
"state": {[]string{"GET"}, "/state", StateHandler(cache)},
"templates": {[]string{"GET"}, "/config/templates/{class:[a-z]+}", TemplatesHandler()},
"getmode": {[]string{"GET"}, "/mode", CurrentChargeModeHandler(site)},
"setmode": {[]string{"POST", "OPTIONS"}, "/mode/{mode:[a-z]+}", ChargeModeHandler(site)},
"gettargetsoc": {[]string{"GET"}, "/targetsoc", CurrentTargetSoCHandler(site)},

View file

@ -40,7 +40,7 @@ func (m *MQTT) encode(v interface{}) string {
case fmt.Stringer, string:
s = fmt.Sprintf("%s", val)
case float64:
s = fmt.Sprintf("%.4g", val)
s = fmt.Sprintf("%.5g", val)
default:
s = fmt.Sprintf("%v", val)
}

View file

@ -94,9 +94,8 @@ func encode(v interface{}) (string, error) {
// must be before stringer to convert to seconds instead of string
s = fmt.Sprintf("%d", int64(val.Seconds()))
case float64:
s = fmt.Sprintf("%.3f", val)
s = fmt.Sprintf("%.5g", val)
default:
// s = fmt.Sprintf("%v", val)
if b, err := json.Marshal(v); err == nil {
s = string(b)
}
@ -104,25 +103,20 @@ func encode(v interface{}) (string, error) {
return s, nil
}
func kv(i util.Param) string {
val, err := encode(i.Val)
func kv(p util.Param) string {
val, err := encode(p.Val)
if err != nil {
log.FATAL.Fatal(err)
panic(err)
}
return "\"" + i.Key + "\":" + val
}
// paramToJSON converts util.Param to JSON as expected by UI
func paramToJSON(p util.Param) string {
var msg strings.Builder
msg.WriteString("{")
msg.WriteString("\"")
if p.LoadPoint != nil {
msg.WriteString(fmt.Sprintf("\"loadpoint\":%d,", *p.LoadPoint))
msg.WriteString(fmt.Sprintf("loadpoints.%d.", *p.LoadPoint))
}
msg.WriteString(kv(p))
msg.WriteString("}")
msg.WriteString(p.Key)
msg.WriteString("\":")
msg.WriteString(val)
return msg.String()
}
@ -130,19 +124,26 @@ func paramToJSON(p util.Param) string {
func (h *SocketHub) welcome(client *SocketClient, params []util.Param) {
h.clients[client] = true
var msg strings.Builder
msg.WriteString("{")
for _, p := range params {
msg := paramToJSON(p)
select {
case client.send <- []byte(msg):
default:
close(client.send)
if msg.Len() > 1 {
msg.WriteString(",")
}
msg.WriteString(kv(p))
}
msg.WriteString("}")
select {
case client.send <- []byte(msg.String()):
default:
close(client.send)
}
}
func (h *SocketHub) broadcast(p util.Param) {
if len(h.clients) > 0 {
msg := paramToJSON(p)
msg := "{" + kv(p) + "}"
for client := range h.clients {
select {

View file

@ -11,7 +11,7 @@ func TestEncode(t *testing.T) {
in, out interface{}
}{
{int64(1), "1"},
{float64(1.2345), "1.234"},
{float64(1.23456), "1.2346"},
{"1.2345", "\"1.2345\""},
{time.Duration(time.Hour), "3600"},
{"minpv", "\"minpv\""},