Kapitel 4: Standalone Front-End
… Auszug …
Echte Messwerte
Bisher haben wir ja nur Werte angezeigt, die unser “Mock” lieferte. Nun wird es Zeit, dass wir uns echte Messwerte anzeigen lassen. Ich gehe im Folgenden davon aus, dass Sie irgend ein Messgerät in ihrer ioBroker Konfiguration eingebunden haben. Hier benutzen wir zur Demonstration ein Homematic IP Innenthermometer / Hygrometer ‘HmIP-STH’ (z.B. hier: https://www.elv.ch/homematic-ip-temperatur-und-luftfeuchtigkeitssensor-innen.html).
Damit ioBroker seine ‘States’ überhaupt an ein Fremdprogramm herausrückt, muss man eine entsprechende Schnittstelle installieren. Gehen Sie auf die ioBroker Admin Seite (homeview.local:8081) und installieren Sie den “SimpleAPI Adapter” aus der Gruppe “Kommunikation. Dieser Adapter bietet auf Port 8087 ein REST API zum Lesen und Schreiben von States an. Lassen Sie bei der Installation alles auf Default-Werten. Zum Testen können Sie nach der Installation folgendes in Ihren Browser eingeben:
http://homeview.local:8087/get/system.adapter.simple-api.0.uptime?prettyPrint
Das Resultat sollte ein JSON Objekt ähnlich wie dieses sein:
{
"val": 785918,
"ack": true,
"ts": 1513019750949,
"q": 0,
"from": "system.adapter.simple-api.0",
"lc": 1513019750949,
"_id": "system.adapter.simple-api.0.uptime",
"type": "state",
"common": {
"name": "simple-api.0.uptime",
"type": "number",
"read": true,
"write": false,
"role": "indicator.state",
"unit": "seconds"
},
"native": {}
}
Wenn Sie statt ‘system.adapter.simple-api.0.uptime’ die ID eines Ihrer Geräte-States eintragen, wird der Zustand dieses States angezeigt. Damit Sie die länglichen IDs nicht abtippen müssen, können Sie sie durch Klick auf “kopieren” direkt in die Zwischenablage nehmen und in den Programmeditor einfügen.
Jetzt können Sie in app.ts das echte Gerät eintragen:
constructor(private ea:EventAggregator){
setInterval(()=>{
this.fetcher.getIobrokerValue("hm-rpc.1.000F570AA11B84.1.ACTUAL_TEMPERATU\
RE").then(result=>{
this.ea.publish("temperatur", result)
},reason=>{
alert("an error occured "+reason)
})
},1000)
}
Allerdings werden Sie, wenn Sie das Programm laufen lassen, immer noch dasselbe wilde Hüpfen des Zeigers sehen, wie zuvor. Das liegt daran, dass der FetchService auch die Variable env.mock beachtet. Die müssen wir nun auf false setzen. Das tun wir in aurelia_project/environments/dev.ts:
export default {
debug: true,
testing: true,
mock: false,
iobroker: 'http://homeview.local:8087'
};
Bei dieser Gelegenheit haben wir auch gleich die korrekte Adresse und Port des ioBroker REST API eingetragen.
(Diesen Stand des Projekts kann ich Ihnen nicht zum Auschecken anbieten, da ich ja nicht weiß, wie die IDs Ihrer Geräte sind. Ich werde in diesem Buch daher weiterhin mit Mock-Werten arbeiten.)
Wenn Sie das Programm jetzt starten, wird der Zeiger unserer CircularGauge nicht mehr so fröhlich umherspringen, sondern uns die reale Temperatur anzeigen. Um die Netzwerkbelastung und den Stromverbrauch nicht unnötig hoch zu lassen, würde ich in app.ts nun auch das Abfrageintervall von 1000 auf etwa 60000 hoch setzen, damit das Thermometer nur noch jede Minute statt jede Sekunde ausgelesen wird.
Natürlich können Sie auch problemlos zwei oder mehr CircularGauges als Thermometer in die Site einbinden. Allerdings werden Sie dann auf ein Problem stossen: Da alle CircularGauges auf dieselbe Nachricht - “temperatur” vom EventAggregator abonniert sind, werden alle Anzeigen auf alle Thermometer reagieren. Wir müssen also auch die Nachricht parametrisierbar machen. Das erreichen wir mit einem zusätzlichen Attribut “message” in cfg, und einer kleinen Anpassung in der attached() Funktion der CircularGauge:
attached() {
this.configure()
this.body = select(this.element).append("svg:svg")
.attr("class", "circulargauge")
.attr("width", this.cfg.size)
.attr("height", this.cfg.size)
this.render()
this.ea.subscribe(this.cfg.message, data => {
this.redraw(data)
})
}
“Subscribe” geht jetzt auf den in this.cfg.message festgelegten String, anstatt auf den hartcodierten Text “temperatur”. So können wir in der Konfiguration jedem Thermometer eine eigene Nachricht mitgeben, auf die es lauschen soll.
Um eine ganze Website mit Innen- und Aussenthermometer zu demonstrieren, brauchen wir eine “vernünftige” Möglichkeit, die IDs er interessierenden Geräte irgendwo zu sammeln. Das Verteilen dieser IDs im ganzen Code ist keine gute Idee, wie Sie spätestens beim Ersetzen eines Geräts nach einem halben Jahr oder so merken würden, wenn Sie keine Ahnung mehr haben, wo im Code die ID für dieses Gerät sich befindet. Stattdessen ergänzen wir unsere Environment-Definitionen config/environment.json und config/environment.production.json um einen Abschnitt “devices”, so dass Sie jetzt so aussehen:
{
"debug": true,
"testing": true,
"mock": false,
"iobroker": "http://homeview.local:8087",
"influx": "http://homeview.local:8086",
"devices":{
"barometer": "mqtt.0.Wetter.Wohnzimmer.Luftdruck",
"aussen_temp": "hm-rpc.0.OEQ00XXXX4.1.TEMPERATURE",
"aussen_hygro": "hm-rpc.0.OEQ00XXXX4.1.HUMIDITY",
"wohnzimmer_temp": "hm-rpc.1.000E5569AXXXXE.1.ACTUAL_TEMPERATURE",
"wohnzimmer_hygro": "hm-rpc.1.000E5569AXXXXE.1.HUMIDITY",
"dusche_temp": "hm-rpc.1.000E5709AXXXX4.1.ACTUAL_TEMPERATURE",
"dusche_hygro": "hm-rpc.1.000E5709AXXXX4.1.HUMIDITY",
"dachstock_temp": "hm-rpc.1.000E5709AXXXX3.1.ACTUAL_TEMPERATURE",
"dachstock_hygro": "hm-rpc.1.000E5709AXXXX3.1.HUMIDITY",
"treppenlicht_direkt": "lightify.0.904AA200AA3XXXXC.on",
"treppenlicht_modus": "javascript.0.aussenlicht_manuell" ,
"fernsehlicht_direkt": "hue.0.Philips_hue.Wohnzimmer.on",
"fernsehlicht_modus": "javascript.0.fernsehlicht_manuell",
"helligkeit": "hm-rpc.0.NEQ0320745.1.BRIGHTNESS",
"esstisch_helligkeit": "hue.0.Philips_hue.Esstisch.bri",
"esstisch_schalter": "hue.0.Philips_hue.Esstisch.on",
"_car_loader_manual": "javascript.0.loadcar_manual",
"_car_loader_state": "mystrom-wifi-switch.1.switchState",
"_car_loader_power": "mystrom-wifi-switch.1.power",
"ACT_POWER": "fronius.0.powerflow.P_PV",
"DAY_ENERGY": "fronius.0.inverter.1.DAY_ENERGY",
"YEAR_ENERGY": "fronius.0.inverter.1.YEAR_ENERGY",
"TOTAL_ENERGY": "fronius.0.inverter.1.TOTAL_ENERGY",
"energy_grid_flow": "fronius.0.powerflow.P_Grid",
"MAX_POWER": 10000,
"MAX_DAILY_ENERGY": 70000
}
}
(Beachten Sie die noch nicht benötigten Einträge einfach nicht :-))
Auf diese Weise muss ich, im Fall eines späteren Gerätetauschs, nur an einer Stelle nachsehen und ändern.
Sie erhalten den Quellcode dieses Teils, wenn Sie eingeben:
git checkout -f origin/stufe_05
git clean -f
npm install
Doch hier die wichtigsten Codeänderungen:
In app.html setzen wir zwei Anzeigen nebeneinander:
<template>
<require from="components/circulargauge"></require>
<div class="container">
<h1 class="h1">Temperatur-Demo</h1>
<div class="row">
<div class="col">
<h2>Aussen</h2>
<circular-gauge cfg.bind="aussentemp_cfg"></circular-gauge>
</div>
<div class="col">
<h2>Wohnzimmer</h2>
<circular-gauge cfg.bind="wohnzimmertemp_cfg"></circular-gauge>
</div>
</div>
</div>
</template>
Hier sehen Sie zwei unterschiedlich parametrisierte CircularGauges nebeneinander. Der entsprechende Code in app.ts braucht vielleicht ein wenig Erläuterung:
import {FetchService} from './services/fetchservice'
import {autoinject} from 'aurelia-framework'
import {EventAggregator} from 'aurelia-event-aggregator'
import * as env from '../config/environment.json'
const dev=env.devices
@autoinject
export class App {
message = 'Hello World!'
fetcher=new FetchService()
wohnzimmertemp_cfg={
"device": dev.wohnzimmer_temp,
"size":250,
bands: [{ from: 10, to: 17, color: "#8cf2e4" },
{from: 17, to: 20, color: "yellow"},
{from: 20, to: 26, color: "green"},
{from: 26, to: 40, color: "red"}],
MAX_ANGLE:300,
min: 10,
max: 40,
message: "wohnzimmer_temp"
}
aussentemp_cfg={
"device": dev.aussen_temp,
"size":250,
bands: [{ from: -20, to: 0, color: "#8cf2e4" },
{from: 0, to: 18, color: "yellow"},
{from: 18, to: 27, color: "green"},
{from: 27, to: 50, color: "red"}],
MAX_ANGLE:300,
min: -20,
max: 50,
message: "aussen_temp"
}
constructor(private ea:EventAggregator){
let devices=[this.wohnzimmertemp_cfg,this.aussentemp_cfg]
setInterval(()=>{
this.fetcher.getIobrokerValues(devices.map(dev=>dev.device))
.then(results=>{
for(let i=0;i<results.length;i++){
this.ea.publish(devices[i].message,results[i])
}
},reason=>{
alert("an error occured "+reason)
})
},10000)
}
}
Wir erstellen zwei Konfigurationsobjekte, wohnzimmertemp_cfg und aussentemp_cfg, die die beiden Geräte referenzieren, und die unterschiedliche Anzeigebereiche und unterschiedliche message-attribute haben. Im constructor packen wir die beiden Objekte in ein Array namens devices. Danach kommt die setInterval-Funktion, die wir schon früher betrachtet haben. Darin rufen wir die getIoBrokerValues()-Funktion des FetchServices aus. Diese holt in einem Rutsch beliebig viele States, deren IDs es in einem Array als Parameter erwartet. Dieses Array erstellen wir on the fly mit devices.map(dev=>dev.device) (Die Javascript-Standardfunktion map erstellt ein Resultat-Array, indem es auf jedes Element des Ursprungs-Arrays eine Operation anwendet, hier dev.device. Es entsteht also ein Array aus Strings, welche die “device”-Attribute jedes Elements von “devices” sind).
Dieses Array ist dann das Argument für getIoBrokerValues(). Als Rückgabewert erhalten wir eine Promise, welche wieder zu einem Array resolved, diesmal einem Array der Resultate, in derselben Reihenfolge, wie die Elemente des Eingangs-Arrays. Diese Resultate lesen wir aus und schicken sie über den EventAggregator zum passenden Empfänger.
Komplexe Figuren erstellen und rotieren
Ich möchte die Entwicklung unserer CircularGauge mit einer letzten Verschönerung abschliessen: Der Zeiger soll nicht einfach nur ein Strich sein, sondern, eben ein Zeiger. Das gibt mir Gelegenheit zu zeigen, wie man Formen jenseits von einfachen geometrischen Figuren mit D3 resp. SVG erstellen kann, und vor allem, wie man solche Figuren verschiebt und rotiert, so dass es natürlich aussieht.
Sie erhalten diesen Stand mit:
git checkout -f origin/stufe_06
git clean -f
npm install
Für komplexe Figuren hält SVG die Elemente Polygon, Polyline und Path bereit, wobei Path das bei Weitem flexibelste ist. Wir hatten es schon bei der arc-Funktion unseres Helper-Objekts kennengelernt, dort hat allerdings D3 uns die Details abgenommen, und wir mussten nur die Parameter des Kreisbogens angeben. Diesmal werden wir selber die Ärmel hochkrempeln und Path von Hand bedienen.
Für die Definition eines Path benutzt SVG eine einfache Beschreibungssprache. Falls Sie TurtleGraphics oder Logo kennen, wird Ihnen das bekannt vorkommen, aber auch ohne dies ist es nicht besonders schwierig. Die wichtigsten Befehle1 sind:
- M - Moveto
- L - Lineto
- C - Curveto
- A - Arc
- Z - Close Path
Ein Rechteck von 10/10 nach 30/20 könnte man zum Beispiel so definieren:
M 10 10 L 30 10 L 30 20 L 10 20 Z
Die Beschreibung darf im Prinzip beliebig lang sein, aber es ist klar, dass es doch arg unübersichtlich und schwer korrigierbar wird, wenn sich so eine Anweisung über eine halbe Seite erstreckt. Dann ist man froh, wenn ein Toolkit wie D3 die Feinarbeit übernimmt.
Für unseren Zeiger reicht aber Handarbeit:
In unserer render() Funktion definieren wir einige Konstanten für den Zeiger, und mit pointer_stroke den Path. Dann erstellen wir ein “g” Element. Die g (group) Elemente dienen in SVG dazu, andere Komponenten zusammenzufassen. Beachten Sie, dass wir dieses g-Element nach der Erstellung sofort nach (center,center) verschieben, und um unsere Skalenrotation drehen. Dies ist darum notwendig, weil SVG Rotationen sich stets auf den Ursprung (0,0) des übergeordneten Elements beziehen. Wenn Sie sehen wollen, was ich meine, kommentieren Sie den transform-Ausdruck einfach mal aus und starten Sie das Programm.
In dieses g Element malen wir dann den Pointer und einen kleinen Kreis ums Zentrum.
render() {
// basic setup
let dim = this.cfg.size
let center = dim / 2
let size = (dim / 2) * 0.9
const pointer_width = 10
const pointer_base = 0.3
const pointer_stroke = `M ${-size * pointer_base} 0
L 0 ${-pointer_width / 2}
L ${size * (1 - pointer_base)} 0
L 0 ${pointer_width / 2}
Z`
this.hlp.frame(this.body, dim)
/*
Draw the coloured bands for the scale
*/
this.cfg.bands.forEach(band => {
this.hlp.arch(this.body, center, center, size - this.arcsize, size,
this.hlp.deg2rad(this.scale(band.from)),
this.hlp.deg2rad(this.scale(band.to)), band.color, this.rotation())
})
// Draw the pointer
let pframe = this.body.append("g")
.attr("transform",
`translate(${center},${center}) rotate(${this.rotation() - 90})`)
this.pointer = pframe.append("g")
this.pointer.append("svg:path")
.attr("d", pointer_stroke)
.classed("pointer", true)
this.pointer.append("svg:circle")
.attr("cx", 0)
.attr("cy", 0)
.attr("r", 8)
/* field for actual measurement */
let valuesFontSize = Math.round(size / 4)
this.valueText = this.hlp.stringElem(this.body, center, center + size / 2,
valuesFontSize, "middle")
/* create tickmarks */
let tickmarkFontSize=this.arcsize/3
this.scale.ticks(15).forEach(tick => {
let p1 = this.valueToPoint(tick, 1.2)
let p2 = this.valueToPoint(tick, 1.0)
let p3= this.valueToPoint(tick,1.35)
this.hlp.line(this.body, center - p1.x, center - p1.y, center - p2.x, cente\
r - p2.y, "black", 1.2)
this.hlp.stringElem(this.body,center-p3.x,center-p3.y,tickmarkFontSize,"mid\
dle").text(tick)
})
this.update(0)
}
Und in update() zeichnen wir jetzt den Zeiger nicht mehr neu, sondern drehen ihn einfach um den gewünschten Winkel:
// called if new value arrives
update(value) {
this.pointer
.transition()
.duration(700)
.attr("transform",`rotate(${this.scale(value)})`)
this.valueText.text(value)
}
Wie Sie sehen wurde zwar das initiale Zeichnen des Zeigers ein wenig komplizierter, aber dafür wurde die update() Funktion wesentlich einfacher. Und das Problem mit der ‘unnatürlichen’ Bewegung des Zeigers, welches wir vorhin hatten, hat sich damit ebenfalls erledigt. Jetzt können wir eine relativ lange ‘duration()’ einstellen, die bewirkt, dass die Drehung des Zeigers recht natürlich wirkt.
Vermutlich haben Sie bemerkt, dass wir dem Zeiger noch eine CSS Klasse mitgegeben haben:
this.pointer.append("svg:path")
.attr("d", pointer_stroke)
.classed("pointer", true)
Solange wir diese Klasse nicht definiert haben, bleibt der Zeiger unschön schwarz. Das wollen wir ändern und schreiben dazu in styles.scss:
svg{
.pointer{
stroke: #ff0000;
fill: #ff1100;
opacity: 0.8;
}
}
Mit dem ‘opacity’ Attribut bewirken wir, dass der Zeiger leicht durchscheinend wirkt. Opacity muss eine Zahl zwischen 0 (ganz durchsichtig) und 1 (ganz undurchsichtig) sein.
Natürlich dürfen Sie den Zeiger gerne noch beliebig schön gestalten, ich wollte Ihnen nur zeigen, wie man eine komplexere SVG Figur erstellen und drehen kann.
Teil 3: DoubleGauge
Nun haben wir so teure Homematic Thermo/Hygrometer angeschafft und lesen nur die Temperatur ab. Natürlich könnten wir je zwei CircularGauges pro Instrument bereitstellen, und je eines für Temperatur und eines für Feuchtigkeitsanzeige parametrisieren. Das Anzeige-Widget ist ja flexibel genug programmiert. Aber das finde ich, ist Platzverschwendung. Vor allem, wenn ich es dann auch auf einem Handy-Bildschirm ablesen will.
Als letzten Teil unserer Exkursion durch die Welt der Rundinstrumente werde ich daher mit Ihnen, wenn Sie wollen, eine DoubleGauge bauen: Ein Rundinstrument mit zwei Anzeigen. Nach unserer Vorarbeit in den letzten Kapiteln ist das eher trivial. Wir müssen einfach jedes Element doppelt programmieren, und vielleicht ein, zwei Gedanken an die entgegengesetzte Drehrichtung des unteren Zeigers aufwenden, dann sollte es klappen. Den Ausgangspunkt dieses Kapitels erhalten Sie mit
git checkout -f origin/stufe_07
git clean -f
npm install
Als erstes definieren wir die Konvention, dass alles, was die obere Anzeige betrifft, mit der präfix ‘upper’ gekennzeichnet wird, alles für die untere Anzeige mit ‘lower’.
Von dieser Verdoppelung mal abgesehen, sieht der Anfang unserer DoubleGauge sehr ähnlich aus, wie der der CircularGauge:
const MIN_ANGLE = 15
const MAX_ANGLE = 165
@autoinject
@noView
export class DoubleGauge {
@bindable cfg;
private arcsize;
private upperScale
private lowerScale
private upperPointer
private lowerPointer
private upperValueText
private lowerValueText
private body
constructor(private hlp: Helper, public element: Element,
private ea: EventAggregator) { }
configure() {
this.cfg = Object.assign({}, {
size: 150,
upperMin: 0,
upperMax: 100,
lowerMin: 0,
lowerMax: 100,
message: ["doublegauge_upper_value",
"doublegauge_lower_value"],
upperBands: [{ from: 0, to: 100, color: "blue" }],
lowerBands: [{ from: 0, to: 100, color: "green" }]
}, this.cfg)
this.upperScale = scaleLinear()
.domain([this.cfg.upperMin, this.cfg.upperMax])
.range([MIN_ANGLE, MAX_ANGLE])
this.upperScale.clamp(true)
this.lowerScale = scaleLinear()
.domain([this.cfg.lowerMin, this.cfg.lowerMax])
.range([MAX_ANGLE, MIN_ANGLE])
this.lowerScale.clamp(true)
this.arcsize = this.cfg.size / 7
}
attached() {
this.configure()
this.body = select(this.element).append("svg:svg")
.attr("class", "circulargauge")
.attr("width", this.cfg.size)
.attr("height", this.cfg.size)
this.render()
this.ea.subscribe(this.cfg.message[0], data => {
this.upperRedraw(data)
})
this.ea.subscribe(this.cfg.message[1], data => {
this.lowerRedraw(data)
})
}
// .... geht nachher noch weiter
MIN_ANGLE und MAX_ANGLE sind zu Konstanten geworden, da es angesichts des beengteren Platzes in unserer Gauge keinen Sinn mehr macht, den Kreisbogen konfigurierbar zu machen.
Beachten Sie, dass die .range in der unteren Skala nicht von MIN_ANGLE zu MAX_ANGLE geht, sondern umgekehrt. Grössere Werte werden hier also auf kleinere Winkel umgesetzt und umgekehrt.
Die attached() Funktion ist fast identisch zu CircularGauge, nur dass wir auf zwei verschiedene Nachrichten lauschen. Theoretisch hätte man natürlich auch eine einzelne Nachricht definieren können, die beide Werte für oben und unten enthält. (Der mit dem EventAggregator übergebene data-Wert kann ein beliebiger Datentyp sein, auch ein Array oder ein beliebig komplex aufgebautes Objekt). Ich habe mich hier aber entschieden, die beiden Teile von DoubleGauge als unabhängige Instrumente zu behandeln, weil sie dadurch flexibler zu handhaben sind.
Spätestens an dieser Stelle fällt nun aber auf, dass wir ziemlich viel geschrieben haben, was fast gleich aussieht, wie in der CircularGauge. Das ist schlecht. Ein wichtiges Prinzip des Programmierens lautet: DRY (Don’t repeat yourself, wiederhole dich nicht). Das hat nicht nur mit Faulheit zu tun, sondern auch mit Folgendem: Wenn Sie irgendwann einen Fehler in einem solchen Stück Code finden, oder eine Verbesserung oder nur Veränderung einbauen, dann müssen Sie mühsam jedes Programmteil suchen, wo Sie diesen Code verwendet haben. Wenn Sie sich aber an DRY gehalten hatten, dann brauchen Sie nur eine einige Stelle zu ändern, und die Änderung erscheint automatisch überall.
Wir machen deshalb hier einen kleinen Exkurs, um den gemeinsamen Code zu definieren
Exkurs: DRY
Wir lagern also den Teil jeder Komponente, der immer gleich ist (auch “boilerplate code” genannt, Textbaustein), in eine gemeinsame Komponente aus. Dazu gibt es verschiedene mögliche Ansätze. Wir können das, was gemeinsam ist, in eine gemeinsame Oberklasse unserer Komponentenklassen packen. Oder wir packen den Initialiserungscode in eine Helferfunktion. So oder so müssen wir uns überlegen, was es ist, das unsere Komponenten ausmacht, wo also die Gemeinsamkeit liegt. In Typescript kann man solche Dinge in einem Interface deklarieren:
export type eaMessage={
message: string,
data: any
}
export interface Component {
configure() // Konfiguration der Komponente
render() // Zeichnen der Komponente
update(data:eaMessage)// Einen neuen Wert anzeigen
cfg: any // Konfigurationsdaten
element: Element // DOM Element
body: Selection // SVG Bssiselement
component_name: String
}
Ein Interface ist einfach eine Beschreibung, welche Eigenschaften ein Objekt mindestens hat, das dieses Interface implementiert. Jede Klasse, die “Component” implementiert, hat also mindestens die oben genannten Funktionen und Felder.
Dann lagern wir den Initialisierungscode in eine initialize() Funktion aus, die wir ebenso wie das Interface in unsere Helper-Klasse verschieben (Das ist eine mehr oder weniger willkürliche Entscheidung. Wir hätten auch eine eigene Klasse dafür erstellen können, oder die Helper-Klasse zu einer Oberklasse unserer Komponenten machen können).
export class Helper {
static BORDER = 5
constructor(public ea: EventAggregator) { }
initialize(component: Component, defaultCfg: any) {
component.cfg = Object.assign(
{
width: component.cfg.width || component.cfg.size || 150,
height: component.cfg.height || component.cfg.size || 150,
modify: a => a
}, defaultCfg, component.cfg)
component.configure()
component.body = select(component.element).append("svg:svg")
.attr("class", component.component_name)
.attr("width", component.cfg.width || component.cfg.size || 180)
.attr("height", component.cfg.height || component.cfg.size || 80)
component.render();
([].concat(component.cfg.message)).forEach(msg => {
this.ea.subscribe(msg, data => {
component.update(<eaMessage>{
message: msg,
data: component.cfg.modify(data)
})
})
});
}
// ... rectangle, arch, stringElem, line und deg2rad hier weggelassen ...
defaultFrame(c: Component): { x: number, y: number, w: number, h: number } {
const yoff = this.frame(c.body, c.cfg.width, c.cfg.height, c.cfg.caption, c.c\
fg.capsize)
return {
x: Helper.BORDER,
y: yoff,
w: c.cfg.width - 2 * Helper.BORDER,
h: c.cfg.height - yoff - Helper.BORDER
}
}
frame(parent, outer_width: number, outer_height: number, caption: string = unde\
fined, capsize: number = outer_height / 8): number {
this.rectangle(parent, 0, 0, outer_width, outer_height, "frame")
let x_offset = Helper.BORDER
let y_offset = Helper.BORDER
let width = outer_width - 2 * Helper.BORDER
let height = outer_height - 2 * Helper.BORDER
if (caption) {
let fontsize = Math.round(capsize)
y_offset = y_offset + fontsize
height = height - fontsize - 2
this.rectangle(parent, x_offset, y_offset, width, height, "inner")
let off = (y_offset - fontsize) / 2
this.stringElem(parent, outer_width / 2, Helper.BORDER + off, fontsize, "mi\
ddle").text(caption)
return y_offset
} else {
this.rectangle(parent, x_offset, y_offset, width, height, "inner")
return Helper.BORDER
}
}
}
Sie sehen, der initialize() - Teil ist recht ähnlich dem, was wir vorher im configure() Teil der Komponente gemacht haben: Zunächst wird ein Default-cfg mit dem per cfg.bind in app.html übergebenen Konfigurationsobjekt überlagert. Etwas seltsam erscheint vielleicht die Zeile modify: a=>a bei (1). Die Auflösung folgt bei (2): Um die Komponente möglichst universell zu gestalten, kann der anzuzeigende Wert anwendungsspezifisch modifiziert werden, bevor er an die Komponente übergeben wird. In diesem Fall kann man in der individuellen cfg eine entsprechende Funktion als ‘modify’ Attribut eintragen. Die Default-Implementation liefert einfach den unmodifizierten Wert (a⇒a). Funktionen wie ‘a⇒1/a’ oder ‘a⇒a*a’ könnten je nach Anwendung eine bessere Grafik ergeben. Einen konkreten Anwendungsfall zeige ich später.
Ausserdem haben wir Helper.ts eine Funktion “defaultFrame” spendiert, die einen Standard-Rahmen um eine Komponente zeichnet, und die Innenmasse des Rahmens, also den eigentlichen Zeichenbereich der Komponente, zurückliefert. Auf diese Weise erzielen wir ein einheitliches Design unserer Komponenten, das wir bei Bedarf auch sehr einfach ändern können.
Also fangen wir noch einmal von Vorne an und erstellen das Grundgerüst unserer DoubleGauge besser.
Falls Sie keine Lust haben, das abzutippen, können Sie es auch auschecken:
git checkout -f origin/stufe_08
git clean -f
npm install
Das hier sind die Anderungen:
import { bindable, noView, autoinject } from 'aurelia-framework'
import { scaleLinear } from 'd3-scale'
import 'd3-transition'
import { Helper, Component, eaMessage } from './helper'
const MIN_ANGLE = 15
const MAX_ANGLE = 165
@autoinject
@noView
export class DoubleGauge implements Component {
@bindable cfg;
component_name = "DoubleGauge";
private arcsize;
private upperScale
private lowerScale
private upperPointer
private lowerPointer
private upperValueText
private lowerValueText
body
constructor(private hlp: Helper, public element: Element) { }
configure() {
this.upperScale = scaleLinear()
.domain([this.cfg.upper.minValue, this.cfg.upper.maxValue])
.range([MIN_ANGLE, MAX_ANGLE])
this.upperScale.clamp(true)
this.lowerScale = scaleLinear()
.domain([this.cfg.lower.minValue, this.cfg.lower.maxValue])
.range([MAX_ANGLE, MIN_ANGLE])
this.lowerScale.clamp(true)
this.arcsize = this.cfg.size / 7
}
attached() {
this.hlp.initialize(this, {
size: 150,
upper: {
minValue: 0,
maxValue: 100,
bands: [{ from: 0, to: 100, color: "blue" }],
},
lower: {
minValue: 0,
maxValue: 100,
bands: [{ from: 0, to: 100, color: "green" }]
},
message: ["doublegauge_upper_value",
"doublegauge_lower_value"],
})
}
render() {
const dim=this.hlp.defaultFrame(this)
let size = { w: (dim.w / 2) * 0.9, h: (dim.h / 2) * 0.9 }
let center= {x: dim.w/2 +dim.x, y: dim.h/2+dim.y}
const pointer_width = 10
const pointer_base = 0.3
const pointer_stroke =
`M ${-size.w * pointer_base} 0
L 0 ${-pointer_width / 2}
L ${size.w * (1 - pointer_base)} 0
L 0 ${pointer_width / 2}
Z`
/*
Draw the coloured bands for the scale
*/
const drawBands = (bands, scale, angle) => {
bands.forEach(band => {
this.hlp.arch(this.body, center.x, center.y,
size.w - this.arcsize, size.w,
this.hlp.deg2rad(scale(band.from)),
this.hlp.deg2rad(scale(band.to)), band.color, angle)
})
}
drawBands(this.cfg.upper.bands, this.upperScale, 270)
drawBands(this.cfg.lower.bands, this.lowerScale, 90)
// Draw the pointers
const pframe = this.body.append("g")
.attr("transform",
`translate(${center.x},${center.y}) rotate(180)`)
this.upperPointer = pframe.append("svg:path")
.attr("d", pointer_stroke)
.classed("pointer", true)
this.lowerPointer = pframe.append("svg:path")
.attr("d", pointer_stroke)
.classed("pointer", true)
pframe.append("svg:circle")
.attr("cx", 0)
.attr("cy", 0)
.attr("r", 10)
/* field for actual measurement */
const valuesFontSize = Math.round(size.h / 5)
this.upperValueText = this.hlp.stringElem(this.body, center.x,
center.y - size.h / 2, valuesFontSize, "middle")
this.lowerValueText = this.hlp.stringElem(this.body, center.x,
center.y + size.h / 2, valuesFontSize, "middle")
/* create tickmarks */
const tickmarkFontSize = this.arcsize / 3
const createTickmarks = (scale, f) => {
scale.ticks().forEach(tick => {
const valueToPoint = (val, factor, scale) => {
let arc = scale(val)
let rad = this.hlp.deg2rad(arc)
let r = ((dim.w / 2) * 0.9 - this.arcsize) * factor
let x = r * Math.cos(rad)
let y = r * Math.sin(rad)
return { x, y }
}
let p1 = valueToPoint(tick, 1.2, scale)
let p2 = valueToPoint(tick, 1.0, scale)
let p3 = valueToPoint(tick, 1.35, scale)
this.hlp.line(this.body, center.x + p1.x * f, center.y + p1.y * f,
center.x + p2.x * f, center.y + p2.y * f, "black", 1.2)
this.hlp.stringElem(this.body, center.x + p3.x * f, center.y + p3.y * f,
tickmarkFontSize, "middle").text(tick)
})
}
createTickmarks(this.upperScale, -1)
createTickmarks(this.lowerScale, 1)
this.upperRedraw(0)
this.lowerRedraw(0)
}
update(newVal: eaMessage) {
if (newVal.message === this.cfg.message[0]) {
this.upperRedraw(newVal.data)
} else {
this.lowerRedraw(newVal.data)
}
}
upperRedraw(upper) {
if (isNaN(upper)) {
this.upperValueText.text("Fehler");
this.upperPointer.attr("style", "opacity:0.1")
} else {
this.upperPointer
.transition()
.duration(600)
.attr("transform", `rotate(${this.upperScale(upper)})`)
this.upperValueText.text(upper + this.cfg.upper.suffix)
}
}
lowerRedraw(lower) {
if (isNaN(lower)) {
this.lowerValueText = lower
this.lowerPointer.attr("style", "opacity:0.1")
} else {
this.lowerPointer
.transition()
.duration(600)
.attr("transform", `rotate(${180 + this.lowerScale(lower)})`)
this.lowerValueText.text(lower + this.cfg.lower.suffix)
}
}
}
Wie Sie sehen, wurde die Initialisierung doch deutlich vereinfacht. Wir werden künftig alle Elemente in dieser Weise erstellen. Sie können dann Ihr eigenes “look&feel” ganz einfach durch ändern von style.css und der defaultFrame() Funktion in helper.ts erstellen.
Wegen der Änderungen in helper.ts sind auch einige kleine Anpassungen in der CircularGauge notwendig. Versuchen Sie es selber zu korrigieren, oder schauen Sie sich an, wie CircularGauge jetzt im Quellcode zu stufe_08 aussieht.
Das Umbauen bereits existierenden Codes, um neue Aspekte oder Verbesserungen einzubringen, nennt man auch ‘Refactoring’. Bei grösseren Projekten kann das ein recht fehlerträchtiger Prozess sein. Es empfiehlt sich darum generell, dass man, sobald man erkennt, dass etwas nicht optimal gelöst wurde, möglichst früh über ein Refactoring nachdenkt, und es dann (und nur dann), wenn die Vorteile die Nachteile überwiegen, zügig umsetzt, dann aber gründlich testet um keinen existierenden Programmcode zu bersehen, der angepasst werden müsste.
Endes des Exkurses
In der render() Funktion mussten wir wieder jeden Schritt verdoppeln. Um nicht alles zweimal schreiben zu müssen, habe ich drawBands() und createTickmarks() als interne Funktionen definiert, die dann jeweils zweimal mit passenden Parametern für die obere und die untere Anzeigehälfte aufgerufen werden.
Und last but not least gibt es nicht nur eine, sondern zwei redraw() Funktionen: upperRedraw() und lowerRedraw().
Der Einbau dieser Komponente in app.html erfolgt erwartungsgemäß:
<template>
<require from="components/circulargauge"></require>
<require from="components/doublegauge"></require>
<div class="container">
<h1 class="h1">Klima-Demo</h1>
<div class="row">
<div class="col">
<double-gauge cfg.bind="conf.aussentemp_cfg">
</double-gauge>
</div>
<div class="col">
<double-gauge cfg.bind="conf.wohnzimmertemp_cfg">
</double-gauge>
</div>
</div>
</div>
</template>
In app.ts habe ich nun aber, wo ich schon mit dem Refactoring beschäftigt war, ebenfalls eine Änderung eingebaut: Der Teil mit den Konfigurationsdefinitionen für die Anzeigen wird immer länger, und er droht mit zunehmender Komplexität des Programms, noch länger zu werden. Das macht app.ts unnötig unübersichtlich. Ich lagere darum den Teil mit den Konfigurationsdateien aus in eine neue Datei namens config.ts:
import env from './environment'
const gauge_size=242;
const switch_size=80;
const climate={
temperature: {
caption: "Temperatur",
suffix: "°C",
minValue: 17,
maxValue: 35,
precision: 1
},
humidity:{
caption: "Luftfeuchte",
suffix: "%",
minValue:20,
maxValue:80,
precision: 0
},
temp_scale: {
bands: [{ from: 10, to: 17, color: "#8cf2e4" },
{ from: 15, to: 20, color: "yellow" },
{ from: 20, to: 26, color: "green" },
{ from: 26, to: 35, color: "red" }],
minValue: 15,
maxValue: 35,
suffix: "°C",
precision: 1
},
humid_scale:{
bands: [{ from: 20, to: 40, color: "yellow" },
{ from: 40, to: 60, color: "green" },
{ from: 60, to: 80, color: "yellow" }],
minValue: 20,
maxValue: 80,
suffix: "%",
precision: 0
}
}
export default {
"SWITCH_ON": 1,
"SWITCH_OFF": 0,
"SWITCH_AUTO": 2,
wohnzimmertemp_cfg: {
"devices": [env.devices.wohnzimmer_temp, env.devices.wohnzimmer_hygro],
"size": gauge_size,
upper: climate.temp_scale,
lower: climate.humid_scale,
message: ["wohnzimmer_temp", "wohnzimmer_hygro"],
caption: "Wohnzimmer",
timeout: 86400,
visible: true
},
aussentemp_cfg: {
"devices": [env.devices.aussen_temp, env.devices.aussen_hygro],
"size": gauge_size,
upper: {
bands: [{ from: -15, to: 0, color: "#8cf2e4" },
{ from: 0, to: 10, color: "yellow" },
{ from: 10, to: 25, color: "green" },
{ from: 25, to: 40, color: "red" }],
minValue: -15,
maxValue: 40,
suffix: "°C",
precision: 1,
},
lower:{
bands: [{ from: 20, to: 40, color: "yellow" },
{ from: 40, to: 60, color: "green" },
{ from: 60, to: 80, color: "yellow" }],
minValue: 20,
maxValue: 80,
suffix: "%",
precision: 0
},
message: ["aussen_temp", "aussen_hygro"],
caption: "Aussen",
timeout: 86400,
visible: true
}
}
Wie Sie sehen, habe ich nebst dem Auslagern der Konfigurationsobjekt auch noch einige Vereinfachungen eingebaut, indem ich Teile, die wiederholt benötigt werden, in die vorgelagerte Konstante ‘climate’ verschob. Dies hat erneut den Vorteil, dass man, wenn man beispielsweise Temperaturskalen verändern will, nur an einer Stelle anpassen muss.
Config.ts wiederum lese ich als externes Modul in app.ts ein.
import { FetchService } from './services/fetchservice'
import { autoinject } from 'aurelia-framework'
import { EventAggregator } from 'aurelia-event-aggregator'
import configs from './config'
import env from './environment'
const dev=env.devices
@autoinject
export class App {
message = 'Hello World!'
fetcher = new FetchService()
conf=configs // (1)
constructor(private ea: EventAggregator) {
let devices = [configs.wohnzimmertemp_cfg, configs.aussentemp_cfg]
let ids = []
let messages = []
devices.forEach(dev => {
ids = ids.concat(dev.devices)
messages = messages.concat(dev.message)
})
setInterval(() => {
this.fetcher.getIobrokerValues(ids).then(results => {
for (let i = 0; i < results.length; i++) {
this.ea.publish(messages[i], results[i])
}
}, reason => {
alert("an error occured " + reason)
})
}, 3000)
}
}
Vielleicht wundern Sie sich über die Zeile: conf=configs bei (1). Schließlich greife ich später in app.ts nicht mehr auf conf zu. Das Rätsel löst sich, wenn Sie etwas weiter oben app.html noch einmal anschauen.
...
<circular-gauge cfg.bind="conf.aussentemp_cfg">
</circular-gauge>
...
Dort benötigen wir die Variable conf, um auf die Konfigurationen zugreifen zu können. Die Binding-Engine hätte nicht direkt einen Ausdruck wie configs.aussentemp_cfg auswerten können. Die View kann nur Variablen lesen, die direkt in ihrem ViewModel definiert sind. Wenn man hier etwas falsch macht, sind die Fehler oft schwer zu finden, weil die IDE einem keine Hilfestellung geben kann. Der Editor “weiß” nichts von der Bindung zwischen View und ViewModel, die Aurelia erst zur Laufzeit herstellt. Lassen Sie die Zeile conf=configs in app.ts mal weg und starten Sie das Programm. Es wird keine Fehlermeldung geben, aber es funktioniert nicht.
Zusammenfassung:
Jetzt haben wir einen deutlich robusteren und lesbareren Grundstock für unsere späteren Komponenten. Ich verlasse daher für jetzt die Rundinstrumente, und überlasse Ihnen die weitere Verschönerung der Zeiger und Skalen, und vielleicht unterschiedliche Farben oder Formen für obere und untere Zeiger, als Übung.
Teil 4: Druckknopf
Bisher haben wir nur Daten abgelesen. Jetzt wollen wir aktiv werden. Also Dinge ein- und ausschalten. Wir fangen mit einem einfachen Druckknopf an. Den Startpunkt dieses Teils erhalten Sie, wenn Sie Folgendes eingeben:
git checkout -f origin/stufe_09
git clean -f
npm install
Ein Druckknopf hat zwei visuell und funktional unterscheidbare Zustände: Gelöst und gedrückt. manchmal bleibt er nur solange gedrückt, wie er bedient wird, manchmal rastet er im gedrückten Zustand ein. Für unsere derzeitige Position als UI-Designer stellt sich die Frage, wie wir den aktuellen Zustand des Schalters darstellen wollen. Ich fange mit einem sehr einfachen Beispiel an, um das Konzept zu zeigen:
Zunächst bereiten wir in app.html ein Plätzchen für unsere neue Komponente:
<template>
<require from="components/doublegauge"></require>
<require from="components/pushbutton"></require>
<div class="container">
<h1 class="h1">Klima-Demo</h1>
<div class="row">
<div class="col">
<double-gauge cfg.bind="conf.aussentemp_cfg">
</double-gauge>
</div>
<div class="col">
<double-gauge cfg.bind="conf.wohnzimmertemp_cfg">
</double-gauge>
</div>
<div class="col">
<push-button cfg.bind="conf.pushbutton_cfg" pressed="pb_on">
</push-button>
</div>
</div>
</div>
</template>
Und sorgen dafür, dass in config.ts die referenzierten Variablen bereit stehen
// ...
pushbutton_cfg: {
message: "treppenlicht",
size: switch_size
},
// ...
Dann erstellen wir die Komponente, diesmal als Einstieg zuerst ganz konventionell mit .ts und .html-Teilen:
import { bindable, autoinject } from 'aurelia-framework'
import { EventAggregator } from 'aurelia-event-aggregator'
@autoinject
export class PushButton{
@bindable cfg
@bindable pressed:boolean;
constructor(private ea:EventAggregator){}
attached(){
this.cfg=Object.assign({},{
message: "pushbutton"
},this.cfg)
}
toggle(){
this.pressed=!this.pressed
this.ea.publish(this.cfg.message,this.pressed)
}
}
<template>
<p>Pushbutton</p>
<div if.bind="pressed" click.trigger="toggle()">
On!
</div>
<div if.bind="!pressed" click.trigger="toggle()">
Off!
</div>
</template>
Möglicherweise kannten Sie das von Aurelia bereitgestellte if.bind Konstrukt bisher noch nicht. Damit binden wir ein beliebiges HTML-Element in Abhängigkeit von einer Bedingung ein, hier abhängig vom Zustand der pressed - Variable im Viewmodel. Starten Sie das Programm und probieren Sie es aus! Wenn Sie auf On! klicken, wird es zu Off! und umgekehrt. Damit ist auch gleich klar geworden, was die click.trigger-Ausdrücke bedeuten: Sie rufen die angegebene Funktion auf, wenn auf das Element geklickt wird.
Nun sind wir natürlich nicht auf die Wörter On und Off limitiert. Wir können beliebig viel beliebiges HTML und SVG in diese DIVs packen, die wir per if.bind ein- und ausblenden.
Zum Beispiel ein Bild:
<template>
<div if.bind="pressed" click.trigger="toggle()">
<img src="img/on_button.png">
</div>
<div if.bind="!pressed" click.trigger="toggle()">
<img src="img/off_button.png">
</div>
</template>
Eine ähnliche Technik hatten wir ja schon im Kapitel über Schalter beim Scripting-host besprochen. Der Nachteil dieser Darstellungsweise ist: Da der Schalter ein Pixel-Bild ist, skaliert er nur bedingt auf andere Grössen. Bei so einer einfachen Figur ist das allerdings kein allzu grosses Problem. Wir können es ja mal testen (Die beiden Button Bilder befindne sich, wenn Sie die aktuellen Quellen ausgecheckt haben, im Ordner “static”. Sie können aber natürlich nach belieben zwei eigene Bilder für “an” und “aus” dort hin kopieren)
<template>
<div if.bind="pressed" click.trigger="toggle()">
<img src="/on_button.png" style="width: 100px">
</div>
<div if.bind="!pressed" click.trigger="toggle()">
<img src="/off_button.png" style="width: 100px">
</div>
</template>
Das sieht immer noch einigermassen akzeptabel aus. Aber schöner wäre natürlich eine Vektorgrafik. Einfaches Beispiel:
<template>
<div if.bind="pressed" click.trigger="toggle()">
<svg width="100px" height="100px">
<rect class="frame" width="100px" height="100px"></rect>
<rect class="inner" x="5px" y="5px" width="90px" height="90px"></rect>
<rect x="10px" y="10px" width="80px" height="80px" fill="#e5fc1b" rx="10px"\
ry="10px"></rect>
</svg>
</div>
<div if.bind="!pressed" click.trigger="toggle()">
<svg width="100px" height="100px">
<rect class="frame" width="100px" height="100px"></rect>
<rect class="inner" x="5px" y="5px" width="90px" height="90px"></rect>
<rect x="10px" y="10px" width="80px" height="80px" fill="#41454c" rx="1\
0px" ry="10px"></rect>
</svg>
</div>
</template>
Doch im Sinn eines einheitlichen Designs unserer Oberfläche erstellen wir den Pushbutton jetzt wieder über unsere Standardmethode, und rein mit d3js ohne HTML Teil. Diesen Teil erhalten Sie mit
git checkout -f origin/stufe_10
git clean -f
npm install
Die Komponente sieht vertraut aus:
import { autoinject, noView, bindable } from "aurelia-framework";
import { Component, eaMessage, Helper } from "./helper";
import { detect } from 'detect-browser'
// Leider will Safari es anderes als die Anderen... (1)
let LINK = "href"
const browser = detect.detect()
if (browser && (browser.name === 'safari' || browser.name === 'ios')) {
LINK = "xlink:href"
}
@autoinject
@noView
export class PushButton2 implements Component {
@bindable cfg
body: any;
component_name: "Push Button"
private state = "off"
private button;
constructor(private hlp: Helper, public element: Element) { }
attached() {
this.hlp.initialize(this, {
size: 110,
message: "pushbutton2",
caption: "Klick mich"
})
}
configure() {}
render() {
const dim=this.hlp.defaultFrame(this)
this.button=this.body.append("svg:image")
.attr(LINK,"/off_button.png")
.attr("x",dim.x)
.attr("y",dim.y)
.attr("width",dim.w)
.attr("height",dim.h)
.on("click",event=>{
this.state=this.state==="on" ? "off" : "on"
if(this.state === "on"){
this.button.attr(LINK,"/on_button.png")
}else{
this.button.attr(LINK,"/off_button.png")
}
})
}
update(val: eaMessage) {
// TODO
}
}
Die Stelle der LINK-Definition bei (1) ist einem Phänomen geschuldet, dass trotz aller Standardisierungsbemühungen immer noch existiert: Browser-Inkompatibilität. Während sonst meistens IE die Rolle des Schlusslichts bei der Implementation von Neuerungen übernimmt, sind es hier Safari und sein mobiler iOS-Kollege: Der Link zu einem eingebetteten Bild in einem SVG Element musste in SVG Version 1 mit dem Attribut “xlink:href” deklariert werden. Diese Form wurde in SVG 2 durch “hlink” ersetzt, und die alte Variante wurde als “deprecated” erklärt (https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href), was bedeutet, dass sie in künftigen Browser-Versionen wohl nicht mehr unterstützt wird. Leider scheint Safari sich hier nicht um Standards zu kümmern: “href” wird schlicht nicht beachtet, und die Bilder werden nur mit “xref:href” korrekt angezeigt (probieren Sie es aus!). Das bedeutet, man muss sich entscheiden, ob man zu Safari inkompatibel sein will, oder zu künftigen Versionen der anderen Browser. Oder man baut, so wie hier, eine Entscheidung in den Code ein. (Um das zu tun müssen Sie npm install --save detect-browser ausführen, damit dieses Tool eingebunden werden kann - wenn Sie teil_11 ausgecheckt haben, habe ich das bereits für Sie getan, und Sie müssen nur noch npm install ausführen).
Generell empfehle ich: Wenn Code sich “seltsam” verhält, testen Sie ihn zunächst in Chrome, der sich meist recht eng an Standards und deren Neuerungen orientiert. Wenn etwas in Chrome erwartungsgemäß funktioniert, in einem anderen Browser aber nicht, dann googeln Sie - meist wird schon jemand dasselbe Problem gehabt haben. Hier lieferte zum Beispiel die Suchanfrage “safari svg href” ausreichend Antworten. Im Zweifelsfall verwenden Sie dann einen Adapter, wie hier gezeigt. Der Fairness halber sei gesagt, dass das nur noch sehr selten nötig ist. Erstens sind Browser generell viel standardkonformer geworden, und zweitens bügeln Framworks wie Aurelia die meisten noch verbliebenen Unterschiede hinter den Kulissen sauber aus.
Ansonsten ist der Code nun frei von überraschenden Stellen. Es wird ein “image”-Element namens “button” eingehängt, und je nachdem, ob das Feld state gerade “off” oder “on” ist, wird das passende Bild angebunden. Bei Klick wird state und das Bild zum jeweils anderen Zustand gewechselt. Binden Sie die Komponente ein, indem Sie in app.html den Teil mit dem Push Button so ändern:
<!-- ... -->
<require from="components/pushbutton2"></require>
<!-- ... -->
<div class="col">
<push-button2 cfg="conf.pushbutton_cfg" pressed="pb_on"></push-button2>
</div>
<!-- -->
Und probieren Sie das Programm aus. Sie können den Schalter ein- und ausschalten, aber natürlich passiert noch nichts, wenn Sie das tun, ausser dass das Bild im Schalter sich ändert.
Wie könnten wir nun zum Beispiel die Aussenbeleuchtung einschalten? Bisher haben wir nur betrachtet, wie wir über das SimpleAPI aus ioBroker States auslesen können. Die Vermutung liegt nahe, dass wir über dasselbe REST API auch States setzen können. Die allgemeine Syntax ist: http://homeview.local:8087/set/<state>?value=x, wobei x für den gewünschten Wert steht.
Um diese Funktion an zentraler Stelle als Service bereitzustellen, schreiben wir eine neue Funktion im services/fetchservice.ts:
public async setIoBrokerValue(id, value) {
if (env.mock) {
return {
"id": id,
"value": value
}
} else {
let raw = await this.http.fetch(`${env.iobroker}/set/${id}?value=${value}`)
let result = await raw.json();
return result
}
}
(Lassen Sie sich nicht davon irritieren, dass ein fetch Befehl abgesetzt wird, um einen Wert zu setzen. Das REST API merkt anhand der URL, dass ein Wert gesetzt werden soll, und der Begriff “fetch” bezieht sich hier auf die Antwort des REST Services. Diese Antwort liefern wir auch zurück.)
Wir könnten jetzt beispielsweise in der toggle() Funktion von pushbutton.ts oder im on('click') Handler von pushbutton2.ts diesen Service aufrufen, und es würde funktionieren. Allerdings würden wir damit unseren Vorsatz verletzen, universelle, lose gekoppelte Komponenten zu programmieren. Dieser Button wäre dann eng ans Treppenlicht gekoppelt, und wenn wir nach ein paar Jahren nicht mehr genau wissen, wie er im Einzelnen funktioniert, dann wird es uns schwer fallen, ihn für ein anderes Projekt anzupassen und zu verwenden.
Daher verwenden wir lieber unsere vorhin schon geübte Technik mit dem EventAggregator, nur eben umgekehrt: Diesmal initiiert die Komponente eine Nachricht, die von Interessenten aufgefangen und ausgewertet werden kann. Wir teilen der PushButton Komponente beim Instanzieren mittels des cfg-Parameters mit, welche Nachricht sie versenden soll, und können dann entspannt darauf warten, dass sie uns diese Nachricht zukommen lässt. Die Komponente braucht nicht zu wissen, was der Empfänger mit der Nachricht zu tun gedenkt.
// ...
render() {
const dim = this.hlp.defaultFrame(this)
this.button = this.body.append("svg:image")
.attr(LINK, "img/off_button.png")
.attr("x", dim.x)
.attr("y", dim.y)
.attr("width", dim.w)
.attr("height", dim.h)
.on("click", event => { //(1)
this.hlp.ea.publish(this.cfg.message,
{ state: (this.state == "on") ? "off" : "on" ,
direction: "out"})
})
}
update(val: eaMessage) {
const img = val.data.state === "on" ? "/on_button.png" : "/off_button.png"
this.state = val.data.state
this.button.attr(LINK, img)
}
...
Da wir ohne View auskommen müssen, können wir nicht, wie beim ersten PushButton, einen click.trigger() im HTML definieren, sondern wir hängen bei (1) einen click-Handler (.on("click",function(){})) an die Definition des Buttons.
Wieso sendet render() eine Nachricht, anstatt den Button einfach direkt im click-Handler korrekt zu setzen? Nun, stellen Sie sich vor, mehrere Leute im Haus haben diese WebApp geöffnet. Jemand drückt auf den Pushbutton, und das Licht geht an resp. aus. Aber was zeigen die anderen Instanzen der WebApp an? Genau: Der PushButton muss nicht nur darauf reagieren, dass er von jemandem gedrückt wird, sondern er muss auch auf die Nachrichten reagieren, die eintreffen, wenn jemand anders woanders darauf gedrückt hat. Eine solche Nachricht wird ja vom Initialisierungscode in Helper.ts zu update() gesendet. Würden wir nun schon im Click-Handler den Zustand des Knopfs ändern, und dann die Nachricht versenden, dann würden wir unsere eigene Nachricht kurz darauf empfangen und den Knopf noch einmal anpassen. Also versenden wir lieber im click.Handler nur die Nachricht und ändern den Knopf erst dann, wenn eine (in unserem Fall die eigene) Nachricht bei update() eintrifft.
Natürlich müssen wir die Nachricht des PushButtons auch irgendwo anders auffangen, damit sie überhaupt irgendeine Wirkung ausser der Änderung der Anzeige hat. Das können wir in jedem Objekt tun, das Zugriff auf den EventAggregator hat. Wir tun es für jetzt gleich in app.ts.
import { FetchService } from './services/fetchservice'
import { autoinject } from 'aurelia-framework'
import { EventAggregator } from 'aurelia-event-aggregator'
import configs from './config'
import env from './environment'
const dev=env.devices
@autoinject
export class App {
message = 'Hello World!'
fetcher = new FetchService()
pb_on=false
conf=configs
constructor(private ea: EventAggregator) {
let devices = [configs.wohnzimmertemp_cfg, configs.aussentemp_cfg]
let ids = []
let messages = []
devices.forEach(dev => {
ids = ids.concat(dev.devices)
messages = messages.concat(dev.message)
})
this.switches()
setInterval(() => {
this.fetcher.getIobrokerValues(ids).then(results => {
for (let i = 0; i < results.length; i++) {
this.ea.publish(messages[i], results[i])
}
}, reason => {
alert("an error occured " + reason)
})
}, 3000)
}
switches(){
this.ea.subscribe(configs.pushbutton_cfg.message,pushed => {
this.fetcher.setIoBrokerValue(dev.treppenlicht_direkt,pushed)
})
}
}
Wie Sie sehen, habe ich eine neue Funktion switches() eingeführt, die die Nachricht des Pushbuttons abfängt und auswertet. Natürlich hätte man den subscribe-Ausdruck auch direkt in den constructor() setzen können, aber dann würde dieser langsam zu gross und unübersichtlich. Eine Faustregel besagt, dass eine einzelne Funktion nicht grösser als eine Bildschirmseite sein sollte.
App.html bleibt unverändert:
<template>
<require from="components/doublegauge"></require>
<require from="components/pushbutton2"></require>
<div class="container">
<h1 class="h1">Klima-Demo</h1>
<div class="row">
<div class="col">
<h2>Aussen</h2>
<double-gauge cfg.bind="conf.aussentemp_cfg"></double-gauge>
</div>
<div class="col">
<h2>Wohnzimmer</h2>
<double-gauge cfg.bind="conf.wohnzimmertemp_cfg"></double-gauge>
</div>
<div class="col">
<h2>Push Button</h2>
<push-button2 cfg="conf.pushbutton2_cfg" pressed="pb_on"></push-button2>
</div>
</div>
</div>
</template>
Teil 5: Tri-State Button
Bevor ich das Reich der Knöpfe verlasse, möchte ich einen weiteren Button-Typ vorstellen, den wir für unsere HomeView-Einrichtung benötigen: Einen Knopf, der die Stellungen Ein, Aus und Automatik einnehmen kann. Wir hatten einen solchen Schalter ja schon mit der Vis-Oberfläche erstellt.
Den Startpunkt dieses Teils erhalten Sie, wenn Sie folgendes eingeben:
git checkout -f origin/stufe_11
git clean -f
npm install
Das Grundgerüst der Klasse TristateButton ist sehr ähnlich wie das der anderen @noView Komponenten; ich gehe darum hier nicht mehr weiter darauf ein. Ich habe in app.html ein neues Feld für den TriState Button eingefügt und in config.ts eine neue cfg. Die TristateButton Klasse beginnt wie gehabt mit configure() und attached(); interessant wird erst die render()-Methode. Hier zeige ich Ihnen zunächst etwas, was wir bisher noch nicht benutzt haben: SVG clipping:
render() {
const dim = this.hlp.defaultFrame(this)
const ratio = 0.6
this.upper = this.hlp.rectangle(this.body, dim.x,
dim.y, dim.w, dim.h, "light_on")
.attr("rx", 15)
.attr("ry", 15)
.attr("clip-path", "url(#clip-bottom)") // (1)
.on("click", () => {
this.ea.publish(this.cfg.message, {
state: this.state === "on" ? "off" : "on",
mode: "manual",
direction: "out"
})
})
this.lower = this.hlp.rectangle(this.body, dim.x,
dim.y, dim.w, dim.h, "mode_auto")
.attr("rx", 15)
.attr("ry", 15)
.attr("clip-path", "url(#clip-top)") // (2)
.on("click", () => {
this.ea.publish(this.cfg.message, {
state: this.state,
mode: this.mode == "auto" ? "manual" : "auto",
direction: "out"
})
})
const defs = this.body.append("svg:defs")
defs.append("svg:clipPath")
.attr("id", "clip-bottom")
.append("svg:rect")
.attr("x", dim.x)
.attr("y", dim.y)
.attr("width", dim.w)
.attr("height", dim.h * ratio)
defs.append("svg:clipPath")
.attr("id", "clip-top")
.append("svg:rect")
.attr("x", dim.x)
.attr("y", dim.y + dim.h * ratio)
.attr("width", dim.w)
.attr("height", dim.h * (1 - ratio))
let fontsize = Math.floor(dim.h / 5)
let text_cx = this.cfg.size / 2
let text_y = Math.round(dim.y + dim.h * ratio + (dim.h * (1 - ratio)) / 2) - 1
let txtgroup = this.body.append("svg:g")
.attr("transform", `translate(${text_cx},${text_y})`)
this.autoText = this.hlp.stringElem(txtgroup, 0, 0, fontsize, "middle")
.text("auto")
this.powerText = this.hlp.stringElem(this.body, text_cx, dim.h / 2, fontsize,\
"middle", 0)
.style("pointer-events", "none");
}
Zunächst erstellen wir zwei Rechtecke mit abgerundeten Ecken. Den Füllstil der Rechtecke, “light_on” etc. haben wir in der styles-Datei hinzugefügt:
svg {
.pointer {
stroke: #ff0000;
fill: #ff1100;
opacity: 0.8;
}
.light_on{
fill: #e5fc1b;
stroke: #a79ea3;
stroke-width: 0.8;
}
.light_off{
fill: #41454c;
}
.mode_auto{
fill: #f714205d;
stroke: #a79ea3;
stroke-width: 0.8;
}
.mode_manual{
fill: #d3d3d3;
stroke: #a79ea3;
stroke-width: 0.8;
opacity: 0.2;
}
}
Das einzig Neue ist das Attribut “clip-path” bei (1) und (2). Mit diesem Attribut weisen wir SVG an, nur einen Teil des Objekts zu zeichnen. Nämlich den Teil, der sich mit der in clip-path definierten (und beliebig komplexen) Struktur überschneidet. Der eine Clip-Path beschneidet das obere, der andere das unter Rechteck.
Diese clip-paths wiederum definieren wir im Element “defs”, welches wir unserem body ebenfalls anhängen, hier unterhalb der Rechtecke, aber der Ort ist für SVG egal. Das Resultat ist jedenfalls, dass wir von jedem Rechteck nur einen Teil sehen.
Schließlich sorgen wir dafür, dass die beiden Teile auf Mausklicks reagieren.
Weiter unten definieren wir noch zwei Textfelder, deren Position und Schriftgrösse wir anhand der Bemaßung ausrechnen, um im oberen und unteren Teil Informationen auszugeben.
Die update() Methode sollte keine großen Probleme machen: Wir geben einfach jedem der Rechtecke die passende Klasse und blenden den Text “auto” ein, wenn der Modus auf auto ist.
Ausserdem wird optional im oberen Teil im eingeschalteten Zustand diese Verbrauchsangabe eingeblendet.
Dieses Textfeld hat keinen Inhalt, wird also standardmäßig nicht angezeigt.
Wir zeigen es in der Funktion update() nur dann an, wenn die Variable power gesetzt ist, und runden es (bei (1)) je nach Größenordnung der Zahl auf keine, eine oder zwei Stellen:
update(val: eaMessage) {
let newstate = val.data
// console.log(val.message+", "+JSON.stringify(val.data))
let bActState = (newstate.state === "on")
let bActMode = (newstate.mode === "auto")
this.upper
.classed("light_on", bActState)
.classed("light_off", !bActState)
this.lower
.classed("mode_manual", !bActMode)
.classed("mode_auto", bActMode)
this.autoText.attr("opacity", bActMode ? 1.0 : 0.2)
if (newstate.power) { // (1)
let tx = newstate.power
if (newstate.power > 1000) {
tx = Math.round(newstate.power)
} else if (newstate.power > 100) {
tx = Math.round(10 * newstate.power) / 10
} else {
tx = Math.round(100 * newstate.power) / 100
}
this.powerText.text(tx + " W")
}
this.state = newstate.state
this.mode = newstate.mode
}
Spielen Sie ein wenig mit dem Button. Wenn Sie auf die untere Fläche klicken, wechseln Sie zwischen auto und manual, wenn Sie auf die obere Fläche klicken, zwischen an und aus, wobei ein Klick auf die obere Fläche ein manueller Eingriff ist, und darum auch in jedem Fall in den Modus “manual” führt.
Allerdings tut unser Button noch nichts. Wir müssen ihn noch verdrahten. Da er ausschliesslich über EventAggregator-Nachrichten gesteuert wird, erfolgt das “verdrahten” drahtlos. Im Grund kann die Steuerung an beliebiger Stelle im Programm liegen. Der Einfachheit halber packen wir sie zu allem Anderen nach app.ts.
Dabei müssen wir berücksichtigen, dass es jetzt nicht mehr um reine Einweg-Kommunikation geht. Der bisherige Aufbau von app.ts genügt nicht, um sowohl Anzeigen (“Licht ist an”), als auch Aktionen (“Schalte Licht an”) zu ermöglichen.
App.ts wurde mit diesen zusätzlichen Abfragen ein wenig unübersichtlich. Ich habe daher die Gelegenheit ergriffen, die Klasse gleich aufzuräumen und ein wenig zu reorganisieren. Also ein Weiteres Refactoring. Sie erhalten diesen Stand des Projekts, wenn Sie eingeben:
git checkout -f origin/stufe_12
git clean-f
npm update
import { FetchService } from './services/fetchservice'
import { autoinject } from 'aurelia-framework'
import { EventAggregator } from 'aurelia-event-aggregator'
import env from './environment'
const dev=env.devices
import configs from './config'
import { tristateMessage } from './components/tristatebutton';
@autoinject
export class App {
fetcher = new FetchService()
conf = configs
constructor(private ea: EventAggregator) {
let gauges = [configs.wohnzimmertemp_cfg, configs.aussentemp_cfg]
let switches = [configs.fernsehlicht_cfg]
this.subscribe(switches)
setInterval(() => {
this.getGaugeValues(gauges)
this.getSwitchValues(switches)
}, 3000)
}
/**
* Subscribe to messages of all configured TriStateButtons
* and send changed values to ioBroker
* @param switches Array with TristateButton configurationa
*/
subscribe(switches) {
switches.forEach(sw => {
this.ea.subscribe(sw.message, msg => {
let value = 2
if (msg.mode == "manual") {
value = msg.state == "on" ? 0 : 1
}
this.fetcher.setIoBrokerValue(sw.mode_id, value)
})
});
}
/**
* Fetch ioBroker values for all configured Gauges
* @param gauges Array with gauge configurations
*/
getGaugeValues(gauges) {
let gauge_ids = []
let gauge_messages = []
gauges.forEach(dev => {
gauge_ids = gauge_ids.concat(dev.devices)
gauge_messages = gauge_messages.concat(dev.message)
})
this.fetcher.getIobrokerValues(gauge_ids).then(results => {
for (let i = 0; i < results.length; i++) {
this.ea.publish(gauge_messages[i], results[i])
}
}, reason => {
alert("an error occured " + reason)
})
}
/**
* Fetch ioBroker values for all configured TristateButtons
* @param switches Array with tristatebutton configurations
*/
getSwitchValues(switches) {
this.fetcher.getIobrokerValues(switches.map(sw => sw.mode_id)).then(async res\
ult => {
for (let i = 0; i < result.length; i++) {
let msg: tristateMessage;
if (result[i] == 2) {
let state = await this.fetcher.getIobrokerValue(switches[i].state_id)
msg = {
state: state ? "on" : "off",
mode: "auto"
}
} else {
msg = {
state: result[i] == 1 ? "off" : "on",
mode: "manual"
}
}
this.ea.publish(switches[i].message, msg)
}
})
}
}
Eine Faustregel lautet, dass eine einzelne Funktion normalerweise nicht grösser als eine Bildschirmseite sein sollte, damit das Programm gut lesbar bleibt. Ich habe darum die Abfragen der Messinstrumente und der Schalter in jeweils eigene Funktionen ausgelagert und rufe in setInterval() diese beiden Funktionen auf. getGaugeValues() entspricht im Wesentlichen dem, was vorher hier stand, während getSwitchValues() neu ist:
Mit switches.map(sw => sw.mode_id) erstellen wir ‘on the fly’ ein Array, welches nur die mode_id Attribute der TristateButton-Konfigurationen enthält, also die im ioBroker Javascript programmatisch erstellten States für die entsprechenden Schaltelemente. Dieses Array senden wir an den fetcher, der die entsprechenden Werte vom Homeview-Server abholt und wieder als Array zurück liefert. Das sind Werte zwischen 0 und 2, die für ein, aus und auto stehen. Der TriStateButton erwartet aber eine Message des Typs tristateMessage, also ein Objekt mit den Attributen state und mode. Damit wir dieses Objekt herstellen können, brauchen wir im Fall von “2” (auto) die zusätzliche Information, ob das Licht derzeit gerade an oder aus ist. Wir deklarieren deshalb die Resultatfunktion für .then als async, damit wir mit await eine zweite Abfrage zum ioBroker schicken können, um den Schaltzustand abzufragen. Dieser hat den Wert true oder false, den wir dann in die vom TristateButton erwartete Form “on” bzw. “off” wandeln. Die so zusammengebastelte Nachricht schicken wir dann, nein, nicht zum TristateButton, sondern wir publizieren sie über den EventAggregator, so dass jeder Interessent sie abfangen kann.
Um den TriStateButton zu verwenden, habe ich in config.ts eine neue Konfiguration für ein Licht eingefügt, die mit dem Fernsehgerät kombiniert ist (Vgl. auch in Kapitel 3 )
// ...
fernsehlicht_cfg: {
"state_id": env.devices.fernsehlicht_direkt,
"mode_id": env.devices.fernsehlicht_modus,
"size": switch_size,
"message": "fernsehlicht",
"caption": "TV-Licht"
}
Teil 6: Lineare Anzeigegeräte
Da wir grössere Stromverbraucher wie Waschmaschine, Tumbler und Geschirrspüler gern dann einschalten, wenn gerade genug Sonnenstrom zur Verfügung steht, wollen wir nun eine Anzeige zum schnellen Überblick über die momentane Stromsituation erstellen. Eine lineare Skala, welche positiven oder negativen Stromfluss anzeigt. Damit wir die neue Anzeige nicht nur hierfür verwenden können, machen wir sie erneut konfigurierbar.
git checkout -f origin/stufe_13
git clean -f
npm installl
Wir beginnen mit dem dem nun schon gut bekannten Grundgerüst:
import {autoinject, bindable, noView} from 'aurelia-framework';
import {EventAggregator} from "aurelia-event-aggregator"
import {scaleLinear} from "d3-scale";
import 'd3-transition'
import {Helper, Component, eaMessage} from './helper'
@autoinject
@noView
export class Lineargauge implements Component{
@bindable cfg
readonly component_name = "Lineargauge"
private scale
body
private indicator
private value
constructor(public element: Element,
private hlp: Helper) {}
attached() {
this.hlp.initialize(this,{
message: "Lineargauge_value",
caption: "",
suffix: "",
minValue : 0,
maxValue : 100,
height : 50,
width : 180,
padding: 0,
bands : [{from: 0, to: 100, color: "blue"}]
})
}
configure() {
this.scale = scaleLinear()
.domain([this.cfg.minValue, this.cfg.maxValue])
.range([5 + this.cfg.padding, this.cfg.width - 5 - this.cfg.padding])
.clamp(true)
}
// ...
// render()
// update()
//.. .
}
Die render() Methode macht etwas ganz Ähnliches, wie die der CircularGauge: Sie malt farbige Streifen und Tickmarks. Nur diesmal eben in Form von Geraden anstatt Kreisbogen. Der Zeiger ist diesmal ein simpler roter Strich. Für die Textanzeige des Messwerts müssen wir ein wenig rechnen, um die Grösse so hinzukriegen, dass alles gut lesbar bleibt. Dann setzen wir sie halbtransparent mitten in die Anzeige.
render() {
// Rahmen
const dim=this.hlp.defaultFrame(this)
const baseline = dim.y+4*dim.h/5
const scalesize=dim.h/10
// draw colored bands
this.cfg.bands.forEach(band => {
this.hlp.line(this.body, this.scale(band.from), baseline, this.scale(band.t\
o), baseline, band.color, scalesize).attr("opacity", 0.5)
})
// draw tick marks and text on every second tick
const ticks = this.scale.ticks(10)
const tickFormat = this.scale.tickFormat(10, "s")
const fontSize = dim.h / 6
let even = true
ticks.forEach(tick => {
const pos = this.scale(tick)
this.hlp.line(this.body, pos, baseline + scalesize/2, pos, baseline - scale\
size, "black", 0.6)
if (even || (tick == 0)) {
this.hlp.stringElem(this.body, pos, baseline-1.5 * fontSize, fontSize, "m\
iddle")
.text(tickFormat(tick))
}
even = !even
})
// Zeiger
this.indicator = this.hlp.line(this.body, this.scale(0), dim.y+1, this.scale(\
0), dim.y+dim.h-2, "red", 1.2)
.attr("id", "indicator1")
// Textanzeige des Messwerts
let valueFontSize = 0.8 * 3*dim.h/5
this.value = this.hlp.stringElem(this.body,
this.scale(this.cfg.minValue + ((this.cfg.maxValue - this.cfg.minValue) / 2\
)),
valueFontSize-3,
valueFontSize,
"middle",
0
).attr("opacity", 0.4).style("fill", "grey")
}
Die update() Funktion ist dann wieder sehr einfach: Wir schieben einfach den Zeiger an die neue Position, die uns von der d3.scaleLinear() aus dem übergebenen Messwert errechnet wird und beschriften das Textfeld neu.
update(newVal:eaMessage) {
const value=newVal.data
const x = this.scale(value)
this.indicator.transition()
.duration(300)
.attr("x1", x)
.attr("x2", x)
this.value.text(value + this.cfg.suffix)
}
Damit die neue Komponente überhaupt angezeigt wird, muss sie in app.html eingetragen werden:
<require from="components/lineargauge"></require>
...
<div>
<lineargauge cfg.bind="conf.powermeter_cfg"></lineargauge>
</div>
und die dort angegebene Konfiguration muss in config.ts vorhanden sein. Und erst mit dieser Konfiguration wird aus einem universellen Linear-Anzeigeinstrument eine Stromfluss-Anzeige:
export default {
// ...
powermeter_cfg: {
devices: [env.devices.energy_grid_flow],
width: gauge_size,
height: switch_size,
minValue: -10000,
maxValue: 10000,
caption: "Netto Strom",
message: "fronius_net",
suffix: "W",
padding: 10,
bands: [
{from: -10000, to: 0, color: "red"},
{from: 0, to: 10000, color: "green"}
],
modify: x => -x
}
// ...
}
Hier sehen Sie auch eine Anwendung der modify-Funktion in cfg: In diesem Fall multiplizieren wir den erhaltenen Wert jeweils mit -1. Der Grund ist: Der ioBroker State für den GRID_FLOW, also den Netto-Fluss von oder zum Elektrizitätswerk, liefert negative Werte für Export und positive Werte für Import von Strom. Ich möchte es aber lieber umgekehrt haben und positive Werte sollen dann angezeigt werden, wenn wir mehr Strom produzieren, als verbrauchen. Daher also alles mal -1.
Zu guter Letzt muss auch app.ts angewiesen werden, die neue Komponente mit Daten zu versorgen. Das ist sehr einfach:
export class app{
// ...
constructor(){
// ...
let gauges = [configs.wohnzimmertemp_cfg, configs.aussentemp_cfg,
configs.powermeter_cfg]
// ...
}
// ...
}
Wir mussten nur unsere powermeter_cfg in die Liste der abzuklappernden gauges eintragen, den Rest erledigt die früher schon geschriebene Logik. Der anfangs erbrachte Aufwand zahlt sich langsam aus.