Thermo-Hygrometer

Für die Weiterentwicklung der Steuerungs-Software machen wir mal wieder an unserem Aurelia-D3-Projekt weiter.

Zur vorgesehenen Heizungssteuerung brauchen wir Temperatur- und Luftfeuchtigkeitsanzeigen. Für eine kompakte Darstellung möchte ich kombinierte Thermo/Hygrometer, etwa so in der Art:


Dazu wird eine Aurelia-Komponente erstellt, die wir in obigem Beispiel zweimal mit unterschiedlichen Parametern eingebunden haben. Den ganzen Quellcode hier zu diskutieren, würde zu weit führen. Sie können bei weitergehendem Interesse das Projekt jederzeit auf Github einsehen und/oder klonen. Hier gehe ich nur auf die Kernelemente ein. Um den Code möglichst verständlich zu halten, wird auf weitergehende Raffinesse verzichtet.

(Anmerkung aufgrund einer Rückmeldung: Der Titel "Draussen" ist kein Schreibfehler, weil diese Komponente sich in der Schweiz befindet. Anwender in Deutschland und Österreich dürfen selbstverständlich gerne "Draußen" schreiben :-))

Dies hier ist nun eher eine Einführung in Aurelia und D3, als in ioBroker, aber da die Anzeige ja schliesslich Werte aus ioBroker ausliest, denke ich, der Artikel ist in diesem Blog schon richtig ;-)

Das HTML der einbettenden Seite klima.html ist einfach:

 1 <template>
 2   <require from="components/doublegauge"></require>
 3   <div class="container">
 4     <div class="row">
 5       <div class="col-sm-3 col-xs-12 col-md-2">
 6         <h3>Draussen</h3>
 7         <doublegauge config.bind="outside_gauge"></doublegauge>
 8       </div>
 9       <div class="col-sm-3 col-xs-12 col-md-2">
10         <h3>Wohnzimmer</h3>
11         <doublegauge config.bind="livingroom_gauge"></doublegauge>
12       </div>
13     </div>
14   </div>
15 </template>

Die Komponente findet sich in components/doublegauge

Auch hier ist das HTML in doublegauge.html trivial:

1 <template>
2   <div class="gaugehost">
3   </div>
4 </template>

Das "Herz" schlägt in doublegauge.ts:

  1 import {autoinject, bindable} from 'aurelia-framework';
  2 import {EventAggregator} from "aurelia-event-aggregator"
  3 import * as d3 from "d3";
  4 
  5 
  6 const MIN_VALUE = 15
  7 const MAX_VALUE = 165
  8 
  9 @autoinject
 10 export class Doublegauge {
 11   @bindable config
 12   private body
 13   private upperScale
 14   private lowerScale
 15   private upperArrow
 16   private lowerArrow
 17   private upperValue
 18   private lowerValue
 19   private arcsize = 15
 20 
 21 
 22   constructor(private ea: EventAggregator, private element: Element) {
 23   }
 24 
 25   attached() {
 26     this.configure()
 27     this.render()
 28     this.ea.subscribe(this.config.event, data => {
 29       this.redraw(data.upper, data.lower)
 30     })
 31   }
 32 
 33   /**
 34    * Configure the component with reasonable defaults. So, the application
 35    * needs only to change presets which are different from the default.
 36    * There are two sets of identical presets: upper... is for the upper gauge,
 37    * lower... defines the lower gauge
 38    **/
 39 
 40   configure() {
 41     /* event the component should listen to for updates.
 42      Must be unique throughout the site
 43      */
 44     this.config.event = this.config.event || "doublegauge_changed"
 45 
 46     /* Size of the component. Height an width are equal */
 47     this.config.size = this.config.size || 150
 48     this.arcsize = this.config.size / 10
 49 
 50     /* minimum value to display */
 51     this.config.upperMin = this.config.upperMin || -10
 52 
 53     /* maximum value to display */
 54     this.config.upperMax = this.config.upperMax || 40
 55 
 56     /* Suffix to display after the value */
 57     this.config.upperSuffix = this.config.upperSuffix || "°C"
 58 
 59     /* Bands with different colors for different value ranges */
 60     this.config.upperBands = this.config.upperBands ||
 61       [{from: this.config.upperMin, to: this.config.upperMax, color: "blue"}]
 62 
 63     /* create a scale to convert values (domain) into degrees (range)
 64      of the gauge pointer */
 65     this.upperScale = d3.scaleLinear()
 66       .domain([this.config.upperMin, this.config.upperMax])
 67       .range([MIN_VALUE, MAX_VALUE])
 68     this.upperScale.clamp(true)
 69 
 70     this.config.lowerMin = this.config.lowerMin || 0
 71     this.config.lowerMax = this.config.lowerMax || 100
 72     this.config.lowerSuffix = this.config.lowerSuffix || "%"
 73     this.config.lowerBands = this.config.lowerBands ||
 74       [{from: this.config.lowerMin, to: this.config.lowerMax, color: "green"}]
 75 
 76     this.lowerScale = d3.scaleLinear()
 77       .domain([this.config.lowerMin, this.config.lowerMax])
 78       .range([MAX_VALUE, MIN_VALUE])
 79     this.lowerScale.clamp(true)
 80   }
 81 
 82   /**
 83    * Initial display of the component
 84    */
 85   render() {
 86     // create unique id and attach SVG container
 87     this.element.id = "dg_" + this.config.event
 88     this.body = d3.select("#" + this.element.id).append("svg:svg")
 89       .attr("class", "doublegauge")
 90       .attr("width", this.config.size)
 91       .attr("height", this.config.size);
 92 
 93     // basic setup
 94     this.rectangle(0, 0, this.config.size, this.config.size,
 95       "black", "#a79ea3")
 96     this.rectangle(5, 5, this.config.size - 10, this.config.size - 10,
 97       "blue", "#d3d3d3")
 98     let center = this.config.size / 2
 99     let size = (this.config.size / 2) * 0.9
100 
101     // create colored bands for the scales
102     this.config.upperBands.forEach(band => {
103       this.arch(center, center, size - this.arcsize, size,
104         this.deg2rad(this.upperScale(band.from)),
105         this.deg2rad(this.upperScale(band.to)), band.color, 270)
106     })
107     this.config.lowerBands.forEach(band => {
108       this.arch(center, center, size - this.arcsize, size,
109         this.deg2rad(this.lowerScale(band.from)),
110         this.deg2rad(this.lowerScale(band.to)), band.color, 90)
111     })
112 
113     // create the pointers, initial reading is 90° for both
114     this.upperArrow = this.arrow(this.body, center, center,
115       center, 10, "red")
116     this.lowerArrow = this.arrow(this.body, center, center,
117       center, center + size, "#0048ff")
118 
119     // Small disc around the axe of the pointers
120     this.body.append("svg:circle")
121       .attr("cx", center)
122       .attr("cy", center)
123       .attr("r", 8)
124       .attr("fill", "#2f1d1c")
125       .attr("stroke", "steelblue")
126 
127     /* fields for actual measurements in the center of the upper and lower scale */
128     let valuesFontSize = Math.round(size / 4)
129     this.upperValue = this.stringElem(center, center - size / 2 + 2,
130       valuesFontSize, "middle")
131     this.lowerValue = this.stringElem(center, center + size / 2 - 2,
132       valuesFontSize, "middle")
133 
134     let markersFontSize = Math.round(size / 6)
135 
136     /* write min and max values to the upper scale */
137     let lp = this.valueToPoint(this.config.upperMin, 0.9, this.upperScale)
138     this.stringElem(center - lp.x, center - lp.y, markersFontSize, "start")
139       .text(this.config.upperMin)
140     lp = this.valueToPoint(this.config.upperMax, 0.9, this.upperScale)
141     this.stringElem(center - lp.x, center - lp.y, markersFontSize, "end")
142       .text(this.config.upperMax)
143 
144     /* write min and max values to the lower scale */
145     lp = this.valueToPoint(this.config.lowerMin, 0.9, this.lowerScale)
146     this.stringElem(center + lp.x, center + lp.y, markersFontSize, "start")
147       .text(this.config.lowerMin)
148     lp = this.valueToPoint(this.config.lowerMax, 0.9, this.lowerScale)
149     this.stringElem(center + lp.x, center + lp.y, markersFontSize, "end")
150       .text(this.config.lowerMax)
151 
152 
153     /* create ticks for upper and lower scales */
154     this.upperScale.ticks().forEach(tick => {
155       let p1 = this.valueToPoint(tick, 1.15, this.upperScale)
156       let p2 = this.valueToPoint(tick, 1.0, this.upperScale)
157       this.body.append("svg:line")
158         .attr("x1", center - p1.x)
159         .attr("y1", center - p1.y)
160         .attr("x2", center - p2.x)
161         .attr("y2", center - p2.y)
162         .attr("stroke", "#464646")
163         .attr("stroke-width", 0.9)
164     })
165     this.lowerScale.ticks().forEach(tick => {
166       let p1 = this.valueToPoint(tick, 1.15, this.lowerScale)
167       let p2 = this.valueToPoint(tick, 1.0, this.lowerScale)
168       this.body.append("svg:line")
169         .attr("x1", center + p1.x)
170         .attr("y1", center + p1.y)
171         .attr("x2", center + p2.x)
172         .attr("y2", center + p2.y)
173         .attr("stroke", "#464646")
174         .attr("stroke-width", 0.9)
175     })
176 
177 
178   }
179 
180   // helper to append a text element
181   stringElem(x, y, size, align) {
182     return this.body.append("svg:text")
183       .attr("x", x)
184       .attr("y", y)
185       .attr("text-anchor", align)
186       .attr("dy", size / 2)
187       .style("font-size", size + "px")
188       .style("fill", "black")
189   }
190 
191   // helper to draw a rectangle
192   rectangle(x, y, w, h, stroke, fill) {
193     this.body.append("svg:rect")
194       .attr("x", x)
195       .attr("y", y)
196       .attr("width", w)
197       .attr("height", h)
198       .attr("stroke", stroke)
199       .attr("fill", fill)
200       .attr("stroke-width", "1")
201   }
202 
203   // helper to draw and position an arch
204   arch(x, y, inner, outer, start, end, color, rotation) {
205     let gen = d3.arc()
206       .startAngle(start)
207       .endAngle(end)
208       .innerRadius(inner)
209       .outerRadius(outer)
210     this.body.append("svg:path")
211       .style("fill", color)
212       .attr("d", gen)
213       .attr("transform", () => {
214         return `translate(${x},${y}) rotate(${rotation})`
215       })
216   }
217 
218   // helper to draw a pointer
219   arrow(parent, cx, cy, x, y, color) {
220     return parent.append("svg:line")
221       .attr("x1", cx)
222       .attr("y1", cy)
223       .attr("x2", x)
224       .attr("y2", y)
225       .attr("stroke", color)
226       .attr("stroke-width", 2)
227   }
228 
229   // helper to convert degrees into radiants
230   deg2rad(deg) {
231     return deg * Math.PI / 180
232   }
233 
234   // helper to convert a value to coordinates
235   valueToPoint(value, factor, scale) {
236     let arc = scale(value)
237     let rad = this.deg2rad(arc)
238     let r = ((this.config.size / 2) * 0.9 - this.arcsize) * factor
239     let x = r * Math.cos(rad)
240     let y = r * Math.sin(rad)
241     return {"x": x, "y": y}
242   }
243 
244   /**
245    * redraw changing elements after an update of the value
246    * @param top new value for the upper gauge
247    * @param bottom new value for the lower gauge
248    */
249   redraw(top, bottom) {
250     let center = this.config.size / 2
251     let valTop = this.upperScale(top)
252     let valBottom = this.lowerScale(bottom)
253     this.upperArrow.attr("transform",
254       `rotate(${valTop - 90},${center},${center})`)
255     this.lowerArrow.attr("transform",
256       `rotate(${valBottom - 90},${center},${center})`)
257     this.upperValue.text(top + this.config.upperSuffix)
258     this.lowerValue.text(bottom + this.config.lowerSuffix)
259   }
260 }

Die "attached"-Methode wird vom Aurelia-Framework aufgerufen, sobald die Komponente im DOM der Seite eingebunden ist. Wir nutzen diesen Moment, um die Konfiguration aufzubauen (configure()), die Komponente zu zeichnen und dem System mitzuteilen, dass sie auf die in config.event genannte Nachricht lauschen will.

In der configure() Methode ab Zeile 40 werden alle relevanten Masse und Angaben eingerichtet, wobei für jeden Eintrag dann der Default gewählt wird, wenn kein Wert übergeben wurde.

Interessant sind vielleicht die "upperScale" und "lowerScale" Einträge. Diese können später verwendet werden, um Werte in Winkelgrade umzurechnen, damit die Zeiger der Anzeige entsprechend gerichtet werden können. D3 erspart uns so mit einer recht simplen Technik einiges an Mathe. Mit der Methode clamp() sorgen wir ausserdem dafür, dass Werte, die ausserhalb des definierten Bereichs liegen, trotzdem in der Skala (genauer gesagt an deren Ende) angezeigt werden. Dadurch vermeiden wir, dass der Zeiger im Nirgendwo steht, wenn der Wert doch einmal höher oder tiefer, als gedacht ist.

In render() ab Zeile 85 wird die Anzeige aufgebaut. Diese besteht aus mehreren Elementen, die wir in einen eigens erstellten SVG Container (this.body) zeichnen lassen. SVG bedeutet "Scalable Vector Graphics" und ist ein WWW Standard zur Anzeige grafischer Elemente. Google findet dazu jede Menge Literatur.

Zunächst wird ab Zeile 94 ein Quadrat in der Größe des gesamten Elements, wie in der Konfiguration angegeben, gezeichnet, mit einem mittel-grauen Hintergrund. Darüber kommt ein um 10 Pixel kleineres Quadrat mit hellgrauem Hintergrund. Der Hintergrund des ersten Quadrats wird somit zum Rahmen.

Ab Zeile 102 werden die farbigen Bögen gezeichnet. Die Komponente erlaubt in der Konfiguration ja beliebig viele Bereiche in unterschiedlichen Farben. Interessant ist hier vielleicht der Transform-Ausdruck in der Helfermethode arch() ab Zeile 213: Der Arc-Generator von D3 nimmt weder Zentrum noch Drehrichtung als Parameter entgegen, sondern platziert den Drehpunkt des Bogens immer bei (0,0). Daher müssen wir diesen Drehpunkt ins Zentrum der Komponente verschieden und den Bogen so drehen, dass er nach iben bzw. unten zeigt.

Die Zeiger werden ab Zeile 114 als simple, 2 Pixel breite Striche gezeichnet. Am Anfang lassen wir sie senkrecht nach oben bzw. unten zeigen, entsprechend 90° oder exakt der Mitte des anzuzeigenden Wertebereichs wie in der Konfiguration angegeben.

Ab Zeile 128 erstellen wir (zunächst leere) Textfelder im mittleren Bereich der Skalen, in die wir später die Messwerte eintragen.

Danach schreiben wir noch die min- und max-Werte der jeweiligen Skalen an die entsprechenden Stellen. Um die Koordinaten dieser "Stellen" zu finden, müssen wir ein wenig Trigonometrie betreiben.



Die Jüngeren können sich vielleicht noch dunkel an die Dinge erinnern, die der Lehrer mit rechtwinkligen Dreiecken anstellte, für die Anderen rekapituliere ich:

x/r = cos(∝), also ist x=r * cos(∝)
y/r = sin(∝), also ist y= r * sin(∝)

Diese Rechnungen erledigt die Hilfsfunktion valueToPoint() ab Zeile 235: Zunächst wird der Messwert von der D3-Scale (die wir in Zeile 65 bzw. 76 aufgebaut haben), in einen Winkelgrad umgerechnet. Mit Hilfe dieses Winkels und des bereits bekannten Radius können wir die Koordinaten x und y errechnen. Als leichte Erschwerung müssen wir die Winkelgrade noch in Radiant (Bogenmass) umrechnen, weil JavaScript das für die Sinus- und Cosinus-Berechnungen gern so haben möchte. Das erledigt die Hilfsfunktion deg2rad().

Mit derselben Rechenmethode zeichnen wir ab Zeile 154 die Teilstriche (ticks) in die Skalen ein. Dabei nutzen wir die Fähigkeit der D3-Scales, mit der Funktion ticks() eine "vernünftige" Anzahl "vernünftig" platzierter Zwischenwerte zu liefern, wobei hier wirklich idR vernünftige Werte herauskommen. (Meist ungefähr 10 Teilstriche, die auf "runden" Werten liegen. Und wenn man damit nicht zufrieden ist, kann man ticks() auch mit Parametern aufrufen, die die gewünschten Eigenschaften spezifizieren - siehe D3-Dokumentation). Alles, was wir nun tun müssen ist, für jeden Tick eine kleine Linie zu zeichnen, deren Endpunkte wir mit unterschiedlichem Faktor für denselben Winkel erhalten.

Wenn uns der Ehrgeiz packt, können wir die Skalen mit dieser Technik später noch mit weiteren Zahlen verzieren. Für den Moment genügen mir die Min- und Max- Werte.

Damit ist das Element fertig aufgebaut, allerdings ist es noch "tot". Um die Werte anzuzeigen, wird es vom Hauptprogramm 'klima.ts' alle paar Sekunden mit den neuesten Messwerten gefüttert, indem die in config.event definierte Nachricht geschickt wird. In Zeile 28 fängt die Komponente diese Nachricht ab und ruft damit die Methode redraw() ab Zeile 249 auf. Diese Methode ist nun beinah trivial: Die beiden Textfelder in den Skalenzentren (upperValue und lowerValue) werden mit den aktuellen Werten gefüttert. Die Zeiger upperArrow und lowerArrow werden einfach um den von der d3-Scale gelieferten Betrag gedreht (wobei wir den Initialwert, zur Erinnerung, 90°, vom gelieferten Winkel abziehen, da SVG Transformationen sich immer auf den Initialwert beziehen).

Wie sieht nun das Hauptprogramm klima.ts aus? Recht simpel:

 1 import {autoinject} from 'aurelia-framework'
 2 import {FetchClient} from './services/fetchclient'
 3 import {EventAggregator} from "aurelia-event-aggregator"
 4 
 5 const server="192.168.16.140:8087"
 6 const _inside_temp='hm-rpc.1.000E5569A24A0E.1.ACTUAL_TEMPERATURE'
 7 const _inside_humid='hm-rpc.1.000E5569A24A0E.1.HUMIDITY'
 8 const _outside_temp='hm-rpc.0.OEQ0088064.1.TEMPERATURE'
 9 const _outside_humid='hm-rpc.0.OEQ0088064.1.HUMIDITY'
10 
11 @autoinject
12 export class Klima{
13   private inside_temp=0
14   private inside_humid=0
15   private outside_temp=0
16   private outside_humid=0
17   private timer=null
18   private outside_gauge
19   private livingroom_gauge
20 
21   constructor(private fetcher:FetchClient, private ea:EventAggregator){
22     this.outside_gauge={
23       event: "outside_data_update",
24       size: 150,
25       upperMin: -20,
26       upperMax: 40,
27       upperSuffix: "°C",
28       upperBands: [{from: -20, to: 0, color: "#1393ff"},{from: 0, to: 10, color: "#bfffcc"},{from:10, to: 25, color: "#ffdea6"},
29         {from: 25, to: 40, color: "#ff5660"}],
30       lowerMin: 20,
31       lowerMax: 80,
32       lowerSuffix: "%",
33       lowerBands: [{from: 20, to: 30, color: "#f00"},{from: 30, to: 40, color: "#f8ff20"}, {from: 40, to:60, color: "#0f0"},
34         {from:60, to:70, color: "#f8ff20"}, {from: 70, to:80, color: "#f00"}]
35     }
36     this.livingroom_gauge=Object.assign({},this.outside_gauge)
37     this.livingroom_gauge.event="livingroom_data_update"
38     this.livingroom_gauge.upperMin=10
39     this.livingroom_gauge.upperMax=35
40     this.livingroom_gauge.upperBands=[{from: 10, to:18, color: "#bff7ff"}, {from: 18, to: 24, color: "#6eff59"},
41       {from: 24, to:35, color: "#ff3c3a"}]
42   }
43 
44   detached(){
45     if(this.timer!=null){
46       clearTimeout(this.timer)
47     }
48     this.timer=null
49   }
50   attached(){
51     this.update()
52     this.timer=setInterval(()=>{
53       this.update()
54     },10000)
55   }
56 
57   async update(){
58     this.inside_temp=await this.fetcher.fetchJson(`http://${server}/get/${_inside_temp}`)
59     this.inside_humid=await this.fetcher.fetchJson(`http://${server}/get/${_inside_humid}`)
60     this.outside_humid=await this.fetcher.fetchJson(`http://${server}/get/${_outside_humid}`)
61     this.outside_temp=await this.fetcher.fetchJson(`http://${server}/get/${_outside_temp}`)
62     this.ea.publish(this.outside_gauge.event,{upper: this.outside_temp,lower:this.outside_humid})
63     this.ea.publish(this.livingroom_gauge.event,{upper:this.inside_temp, lower: this.inside_humid})
64   }
65 }

Wir definieren zunächst die Messgeräte, den Homematic Aussensensor und den Homematic IP Innensensor, die wie im letzten Post gezeigt an ioBroker angebunden wurden. Im constructor() ab Zeile 21 bauen wir die Konfiguration für die (im Moment 2) Anzeigen auf, wobei die meisten Werte identisch sind. Wichtig ist, dass jede eine eigene Event-Bezeichnung bekommt, damit sie die richtigen Werte abfängt. In der attached() Methode ab Zeile 50 wird ein Timer aufgebaut, der alle 10 Sekunden die Messwerte ausliest und als Nachrichten versendet (welche die Anzeigen dann wiederum abfangen). In der detached() Methode ab Zeile 44 wird der Timer gestoppt, da das Auslesen und Versenden von Nachrichten witzlos ist, wenn die Anzeigen gar nicht sichtbar sind, weil der Anwender zum Beispiel einen anderen Menüpunkt ausgewählt hat.

So, das war ein etwas längerer Post, ich hoffe, er war verständlich. Vielleicht konnte ich ja hiermit die Lernkurve für jemand anders (oder für mich selbst wenn ich es in einigen Jahren wieder vergessen habe), ein wenig abflachen.

Kommentare

Beliebte Posts aus diesem Blog

von Schedules und Triggern

myStrom WiFi Button an ioBroker anbinden

Einfache Script-Beispiele