]> git.basschouten.com Git - openhab-addons.git/blob
442c8521ac300b827d5d378bbedefe586adf89ce
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.guntamatic.internal;
14
15 import static org.openhab.binding.guntamatic.internal.GuntamaticBindingConstants.*;
16
17 import java.nio.charset.Charset;
18 import java.util.ArrayList;
19 import java.util.HashMap;
20 import java.util.List;
21 import java.util.Map;
22 import java.util.concurrent.ExecutionException;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
25 import java.util.concurrent.TimeoutException;
26
27 import javax.measure.Unit;
28
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.eclipse.jetty.client.HttpClient;
32 import org.eclipse.jetty.client.api.ContentResponse;
33 import org.eclipse.jetty.client.api.Request;
34 import org.eclipse.jetty.http.HttpHeader;
35 import org.eclipse.jetty.http.HttpMethod;
36 import org.eclipse.jetty.http.HttpStatus;
37 import org.openhab.core.library.CoreItemFactory;
38 import org.openhab.core.library.types.DecimalType;
39 import org.openhab.core.library.types.OnOffType;
40 import org.openhab.core.library.types.QuantityType;
41 import org.openhab.core.library.types.StringType;
42 import org.openhab.core.library.unit.ImperialUnits;
43 import org.openhab.core.library.unit.SIUnits;
44 import org.openhab.core.library.unit.Units;
45 import org.openhab.core.thing.Channel;
46 import org.openhab.core.thing.ChannelUID;
47 import org.openhab.core.thing.Thing;
48 import org.openhab.core.thing.ThingStatus;
49 import org.openhab.core.thing.ThingStatusDetail;
50 import org.openhab.core.thing.binding.BaseThingHandler;
51 import org.openhab.core.thing.binding.builder.ChannelBuilder;
52 import org.openhab.core.thing.binding.builder.ThingBuilder;
53 import org.openhab.core.thing.type.ChannelKind;
54 import org.openhab.core.thing.type.ChannelTypeUID;
55 import org.openhab.core.types.Command;
56 import org.openhab.core.types.RefreshType;
57 import org.openhab.core.types.State;
58 import org.slf4j.Logger;
59 import org.slf4j.LoggerFactory;
60
61 import com.google.gson.JsonArray;
62 import com.google.gson.JsonObject;
63 import com.google.gson.JsonParseException;
64 import com.google.gson.JsonParser;
65
66 /**
67  * The {@link GuntamaticHandler} is responsible for handling commands, which are
68  * sent to one of the channels.
69  *
70  * @author Weger Michael - Initial contribution
71  */
72 @NonNullByDefault
73 public class GuntamaticHandler extends BaseThingHandler {
74
75     private static final String NUMBER_TEMPERATURE = CoreItemFactory.NUMBER + ":Temperature";
76     private static final String NUMBER_VOLUME = CoreItemFactory.NUMBER + ":Volume";
77     private static final String NUMBER_TIME = CoreItemFactory.NUMBER + ":Time";
78     private static final String NUMBER_DIMENSIONLESS = CoreItemFactory.NUMBER + ":Dimensionless";
79
80     private static final Map<String, Unit<?>> MAP_UNIT = Map.of("%", Units.PERCENT, "°C", SIUnits.CELSIUS, "°F",
81             ImperialUnits.FAHRENHEIT, "m3", SIUnits.CUBIC_METRE, "d", Units.DAY, "h", Units.HOUR);
82     private static final Map<Unit<?>, String> MAP_UNIT_ITEMTYPE = Map.of(Units.PERCENT, NUMBER_DIMENSIONLESS,
83             SIUnits.CELSIUS, NUMBER_TEMPERATURE, ImperialUnits.FAHRENHEIT, NUMBER_TEMPERATURE, SIUnits.CUBIC_METRE,
84             NUMBER_VOLUME, Units.DAY, NUMBER_TIME, Units.HOUR, NUMBER_TIME);
85
86     private final Logger logger = LoggerFactory.getLogger(GuntamaticHandler.class);
87     private final HttpClient httpClient;
88
89     private @Nullable ScheduledFuture<?> pollingFuture = null;
90
91     private GuntamaticConfiguration config = new GuntamaticConfiguration();
92     private Boolean channelsInitialized = false;
93     private GuntamaticChannelTypeProvider guntamaticChannelTypeProvider;
94     private Map<Integer, String> channels = new HashMap<>();
95     private Map<Integer, String> types = new HashMap<>();
96     private Map<Integer, Unit<?>> units = new HashMap<>();
97
98     public GuntamaticHandler(Thing thing, HttpClient httpClient,
99             GuntamaticChannelTypeProvider guntamaticChannelTypeProvider) {
100         super(thing);
101         this.httpClient = httpClient;
102         this.guntamaticChannelTypeProvider = guntamaticChannelTypeProvider;
103     }
104
105     @Override
106     public void handleCommand(ChannelUID channelUID, Command command) {
107         if (!(command instanceof RefreshType)) {
108             if (!config.key.isBlank()) {
109                 String param;
110                 String channelID = channelUID.getId();
111                 switch (channelID) {
112                     case CHANNEL_CONTROLBOILERAPPROVAL:
113                         param = getThing().getProperties().get(PARAMETER_BOILERAPPROVAL);
114                         break;
115                     case CHANNEL_CONTROLPROGRAM:
116                         param = getThing().getProperties().get(PARAMETER_PROGRAM);
117                         break;
118                     case CHANNEL_CONTROLHEATCIRCPROGRAM0:
119                     case CHANNEL_CONTROLHEATCIRCPROGRAM1:
120                     case CHANNEL_CONTROLHEATCIRCPROGRAM2:
121                     case CHANNEL_CONTROLHEATCIRCPROGRAM3:
122                     case CHANNEL_CONTROLHEATCIRCPROGRAM4:
123                     case CHANNEL_CONTROLHEATCIRCPROGRAM5:
124                     case CHANNEL_CONTROLHEATCIRCPROGRAM6:
125                     case CHANNEL_CONTROLHEATCIRCPROGRAM7:
126                     case CHANNEL_CONTROLHEATCIRCPROGRAM8:
127                         param = getThing().getProperties().get(PARAMETER_HEATCIRCPROGRAM).replace("x",
128                                 channelID.substring(channelID.length() - 1));
129                         break;
130                     case CHANNEL_CONTROLWWHEAT0:
131                     case CHANNEL_CONTROLWWHEAT1:
132                     case CHANNEL_CONTROLWWHEAT2:
133                         param = getThing().getProperties().get(PARAMETER_WWHEAT).replace("x",
134                                 channelID.substring(channelID.length() - 1));
135                         break;
136                     case CHANNEL_CONTROLEXTRAWWHEAT0:
137                     case CHANNEL_CONTROLEXTRAWWHEAT1:
138                     case CHANNEL_CONTROLEXTRAWWHEAT2:
139                         param = getThing().getProperties().get(PARAMETER_EXTRAWWHEAT).replace("x",
140                                 channelID.substring(channelID.length() - 1));
141                         break;
142                     default:
143                         return;
144                 }
145                 String response = sendGetRequest(PARSET_URL, "syn=" + param, "value=" + command.toString());
146                 if (response != null) {
147                     State newState = new StringType(response);
148                     updateState(channelID, newState);
149                 }
150             } else {
151                 logger.warn("A 'key' needs to be configured in order to control the Guntamatic Heating System");
152             }
153         }
154     }
155
156     private void parseAndUpdate(String html) {
157         String[] daqdata = html.split("\\n");
158
159         for (Integer i : channels.keySet()) {
160             String channel = channels.get(i);
161             Unit<?> unit = units.get(i);
162             if ((channel != null) && (i < daqdata.length)) {
163                 String value = daqdata[i];
164                 Channel chn = thing.getChannel(channel);
165                 if ((chn != null) && (value != null)) {
166                     value = value.trim();
167                     String typeName = chn.getAcceptedItemType();
168                     try {
169                         State newState = null;
170                         if (typeName != null) {
171                             switch (typeName) {
172                                 case CoreItemFactory.SWITCH:
173                                     // Guntamatic uses German OnOff when configured to German and English OnOff for
174                                     // all other languages
175                                     if ("ON".equals(value) || "EIN".equals(value)) {
176                                         newState = OnOffType.ON;
177                                     } else if ("OFF".equals(value) || "AUS".equals(value)) {
178                                         newState = OnOffType.OFF;
179                                     }
180                                     break;
181                                 case CoreItemFactory.NUMBER:
182                                     newState = new DecimalType(value);
183                                     break;
184                                 case NUMBER_DIMENSIONLESS:
185                                 case NUMBER_TEMPERATURE:
186                                 case NUMBER_VOLUME:
187                                 case NUMBER_TIME:
188                                     if (unit != null) {
189                                         newState = new QuantityType<>(Double.parseDouble(value), unit);
190                                     }
191                                     break;
192                                 case CoreItemFactory.STRING:
193                                     newState = new StringType(value);
194                                     break;
195                                 default:
196                                     break;
197                             }
198                         }
199                         if (newState != null) {
200                             updateState(channel, newState);
201                         } else {
202                             logger.warn("Data for unknown typeName '{}' or unknown unit received", typeName);
203                         }
204                     } catch (NumberFormatException e) {
205                         logger.warn("NumberFormatException: {}", ((e.getMessage() != null) ? e.getMessage() : ""));
206                     }
207                 }
208             } else {
209                 logger.warn("Data for not intialized ChannelId '{}' received", i);
210             }
211         }
212     }
213
214     private void parseAndJsonInit(String html) {
215         try {
216             // remove non JSON compliant, empty element ",,"
217             JsonArray json = JsonParser.parseString(html.replace(",,", ",")).getAsJsonArray();
218             for (int i = 1; i < json.size(); i++) {
219                 JsonObject points = json.get(i).getAsJsonObject();
220                 if (points.has("id") && points.has("type")) {
221                     int id = points.get("id").getAsInt();
222                     String type = points.get("type").getAsString();
223                     types.put(id, type);
224                 }
225             }
226         } catch (JsonParseException | IllegalStateException | ClassCastException e) {
227             logger.warn("Invalid JSON data will be ignored: '{}'", html.replace(",,", ","));
228         }
229     }
230
231     private void parseAndInit(String html) {
232         String[] daqdesc = html.split("\\n");
233         List<Channel> channelList = new ArrayList<>();
234
235         for (String channelID : CHANNELIDS) {
236             Channel channel = thing.getChannel(channelID);
237             if (channel == null) {
238                 logger.warn("Static Channel '{}' is not present: remove and re-add Thing", channelID);
239             } else {
240                 channelList.add(channel);
241             }
242         }
243
244         for (int i = 0; i < daqdesc.length; i++) {
245             String[] param = daqdesc[i].split(";");
246             String label = param[0].replace("C02", "CO2");
247
248             if (!"reserved".equals(label)) {
249                 String channel = toLowerCamelCase(replaceUmlaut(label));
250                 label = label.substring(0, 1).toUpperCase() + label.substring(1);
251
252                 String unitStr = ((param.length == 1) || param[1].isBlank()) ? "" : param[1].trim();
253                 Unit<?> unit = guessUnit(unitStr);
254
255                 boolean channelInitialized = channels.containsValue(channel);
256                 if (!channelInitialized) {
257                     String itemType;
258                     String pattern;
259                     String type = types.get(i);
260                     if (type == null) {
261                         type = "";
262                     }
263
264                     if ("boolean".equals(type)) {
265                         itemType = CoreItemFactory.SWITCH;
266                         pattern = "";
267                     } else if ("integer".equals(type)) {
268                         itemType = guessItemType(unit);
269                         pattern = "%d";
270                         if (unit != null) {
271                             pattern += " %unit%";
272                         }
273                     } else if ("float".equals(type)) {
274                         itemType = guessItemType(unit);
275                         pattern = "%.2f";
276                         if (unit != null) {
277                             pattern += " %unit%";
278                         }
279                     } else if ("string".equals(type)) {
280                         itemType = CoreItemFactory.STRING;
281                         pattern = "%s";
282                     } else {
283                         if (unitStr.isBlank()) {
284                             itemType = CoreItemFactory.STRING;
285                             pattern = "%s";
286                         } else {
287                             itemType = guessItemType(unit);
288                             pattern = "%.2f";
289                             if (unit != null) {
290                                 pattern += " %unit%";
291                             }
292                         }
293                     }
294
295                     ChannelTypeUID channelTypeUID = new ChannelTypeUID(BINDING_ID, channel);
296                     guntamaticChannelTypeProvider.addChannelType(channelTypeUID, channel, itemType,
297                             "Guntamatic " + label, false, pattern);
298                     Channel newChannel = ChannelBuilder.create(new ChannelUID(thing.getUID(), channel), itemType)
299                             .withType(channelTypeUID).withKind(ChannelKind.STATE).withLabel(label).build();
300                     channelList.add(newChannel);
301                     channels.put(i, channel);
302                     if (unit != null) {
303                         units.put(i, unit);
304                     }
305
306                     logger.debug(
307                             "Supported Channel: Idx: '{}', Name: '{}'/'{}', Type: '{}'/'{}', Unit: '{}', Pattern '{}' ",
308                             String.format("%03d", i), label, channel, type, itemType, unitStr, pattern);
309                 }
310             }
311         }
312         ThingBuilder thingBuilder = editThing();
313         thingBuilder.withChannels(channelList);
314         updateThing(thingBuilder.build());
315         channelsInitialized = true;
316     }
317
318     private @Nullable Unit<?> guessUnit(String unit) {
319         Unit<?> finalUnit = MAP_UNIT.get(unit);
320         if (!unit.isBlank() && (finalUnit == null)) {
321             logger.warn("Unsupported unit '{}' detected", unit);
322         }
323         return finalUnit;
324     }
325
326     private String guessItemType(@Nullable Unit<?> unit) {
327         String itemType = (unit != null) ? MAP_UNIT_ITEMTYPE.get(unit) : CoreItemFactory.NUMBER;
328         if (itemType == null) {
329             itemType = CoreItemFactory.NUMBER;
330             logger.warn("Unsupported unit '{}' detected: using native '{}' type", unit, itemType);
331         }
332         return itemType;
333     }
334
335     private static String replaceUmlaut(String input) {
336         // replace all lower Umlauts
337         String output = input.replace("ü", "ue").replace("ö", "oe").replace("ä", "ae").replace("ß", "ss");
338
339         // first replace all capital umlaute in a non-capitalized context (e.g. Übung)
340         output = output.replaceAll("Ü(?=[a-zäöüß ])", "Ue").replaceAll("Ö(?=[a-zäöüß ])", "Oe")
341                 .replaceAll("Ä(?=[a-zäöüß ])", "Ae");
342
343         // now replace all the other capital umlaute
344         output = output.replace("Ü", "UE").replace("Ö", "OE").replace("Ä", "AE");
345
346         return output;
347     }
348
349     private String toLowerCamelCase(String input) {
350         char delimiter = ' ';
351         String output = input.replace("´", "").replaceAll("[^\\w]", String.valueOf(delimiter));
352
353         StringBuilder builder = new StringBuilder();
354         boolean nextCharLow = true;
355
356         for (int i = 0; i < output.length(); i++) {
357             char currentChar = output.charAt(i);
358             if (delimiter == currentChar) {
359                 nextCharLow = false;
360             } else if (nextCharLow) {
361                 builder.append(Character.toLowerCase(currentChar));
362             } else {
363                 builder.append(Character.toUpperCase(currentChar));
364                 nextCharLow = true;
365             }
366         }
367         return builder.toString();
368     }
369
370     private @Nullable String sendGetRequest(String url, String... params) {
371         String errorReason = "";
372         String req = "http://" + config.hostname + url;
373
374         if (!config.key.isBlank()) {
375             req += "?key=" + config.key;
376         }
377
378         for (int i = 0; i < params.length; i++) {
379             if ((i == 0) && config.key.isBlank()) {
380                 req += "?";
381             } else {
382                 req += "&";
383             }
384             req += params[i];
385         }
386
387         Request request = httpClient.newRequest(req);
388         request.method(HttpMethod.GET).timeout(30, TimeUnit.SECONDS).header(HttpHeader.ACCEPT_ENCODING, "gzip");
389
390         try {
391             ContentResponse contentResponse = request.send();
392             if (HttpStatus.OK_200 == contentResponse.getStatus()) {
393                 if (!this.getThing().getStatus().equals(ThingStatus.ONLINE)) {
394                     updateStatus(ThingStatus.ONLINE);
395                 }
396                 try {
397                     String response = new String(contentResponse.getContent(), Charset.forName(config.encoding));
398                     if (url.equals(DAQEXTDESC_URL)) {
399                         parseAndJsonInit(response);
400                     } else if (url.equals(DAQDATA_URL)) {
401                         parseAndUpdate(response);
402                     } else if (url.equals(DAQDESC_URL)) {
403                         parseAndInit(response);
404                     }
405                     // PARSET_URL via return
406                     return response;
407                 } catch (IllegalArgumentException e) {
408                     errorReason = String.format("IllegalArgumentException: %s",
409                             ((e.getMessage() != null) ? e.getMessage() : ""));
410                 }
411             } else {
412                 errorReason = String.format("Guntamatic request failed with %d: %s", contentResponse.getStatus(),
413                         ((contentResponse.getReason() != null) ? contentResponse.getReason() : ""));
414             }
415         } catch (TimeoutException e) {
416             errorReason = "TimeoutException: Guntamatic was not reachable on your network";
417         } catch (ExecutionException e) {
418             errorReason = String.format("ExecutionException: %s", ((e.getMessage() != null) ? e.getMessage() : ""));
419         } catch (InterruptedException e) {
420             Thread.currentThread().interrupt();
421             errorReason = String.format("InterruptedException: %s", ((e.getMessage() != null) ? e.getMessage() : ""));
422         }
423         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, errorReason);
424         return null;
425     }
426
427     private void pollGuntamatic() {
428         if (!channelsInitialized) {
429             if (!config.key.isBlank()) {
430                 sendGetRequest(DAQEXTDESC_URL);
431             }
432             sendGetRequest(DAQDESC_URL);
433         } else {
434             sendGetRequest(DAQDATA_URL);
435         }
436     }
437
438     @Override
439     public void initialize() {
440         config = getConfigAs(GuntamaticConfiguration.class);
441         if (config.hostname.isBlank()) {
442             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid hostname configuration");
443         } else {
444             updateStatus(ThingStatus.UNKNOWN);
445             pollingFuture = scheduler.scheduleWithFixedDelay(this::pollGuntamatic, 1, config.refreshInterval,
446                     TimeUnit.SECONDS);
447         }
448     }
449
450     @Override
451     public void dispose() {
452         final ScheduledFuture<?> job = pollingFuture;
453         if (job != null) {
454             job.cancel(true);
455             pollingFuture = null;
456         }
457         channelsInitialized = false;
458     }
459 }