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