2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.guntamatic.internal;
15 import static org.openhab.binding.guntamatic.internal.GuntamaticBindingConstants.*;
17 import java.nio.charset.Charset;
18 import java.util.ArrayList;
19 import java.util.HashMap;
20 import java.util.List;
22 import java.util.concurrent.ExecutionException;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
25 import java.util.concurrent.TimeoutException;
27 import javax.measure.Unit;
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;
62 import com.google.gson.JsonArray;
63 import com.google.gson.JsonObject;
64 import com.google.gson.JsonParseException;
65 import com.google.gson.JsonParser;
68 * The {@link GuntamaticHandler} is responsible for handling commands, which are
69 * sent to one of the channels.
71 * @author Weger Michael - Initial contribution
74 public class GuntamaticHandler extends BaseThingHandler {
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";
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);
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",
90 private static final Map<String, String> MAP_COMMAND_PARAM_PROG_WOMANU = Map.of("OFF", "0", "NORMAL", "1",
92 private static final Map<String, String> MAP_COMMAND_PARAM_HC = Map.of("OFF", "0", "NORMAL", "1", "HEAT", "2",
94 private static final Map<String, String> MAP_COMMAND_PARAM_WW = Map.of("RECHARGE", "0");
96 private final Logger logger = LoggerFactory.getLogger(GuntamaticHandler.class);
97 private final HttpClient httpClient;
99 private @Nullable ScheduledFuture<?> pollingFuture = null;
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<>();
109 public GuntamaticHandler(Thing thing, HttpClient httpClient,
110 GuntamaticChannelTypeProvider guntamaticChannelTypeProvider, List<String> staticChannelIDs) {
112 this.httpClient = httpClient;
113 this.guntamaticChannelTypeProvider = guntamaticChannelTypeProvider;
114 this.staticChannelIDs = staticChannelIDs;
118 public void handleCommand(ChannelUID channelUID, Command command) {
119 if (!(command instanceof RefreshType)) {
120 if (!config.key.isBlank()) {
122 Map<String, String> map;
123 String channelID = channelUID.getId();
125 case CHANNEL_CONTROLBOILERAPPROVAL:
126 param = getThing().getProperties().get(PARAMETER_BOILERAPPROVAL);
127 map = MAP_COMMAND_PARAM_APPROVAL;
129 case CHANNEL_CONTROLPROGRAM:
130 param = getThing().getProperties().get(PARAMETER_PROGRAM);
131 ThingTypeUID thingTypeUID = getThing().getThingTypeUID();
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;
138 map = MAP_COMMAND_PARAM_PROG_WOMANU;
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;
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;
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;
172 String cmd = command.toString().trim();
173 if (map.containsValue(cmd)) {
175 } else if (map.containsKey(cmd)) {
178 logger.warn("Invalid command '{}' for channel '{}' received ", cmd, channelID);
182 String response = sendGetRequest(PARSET_URL, "syn=" + param, "value=" + cmd);
183 if (response != null) {
184 State newState = new StringType(response);
185 updateState(channelID, newState);
188 logger.warn("A 'key' needs to be configured in order to control the Guntamatic Heating System");
193 private void parseAndUpdate(String html) {
194 String[] daqdata = html.split("\\n");
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();
206 State newState = null;
207 if (typeName != null) {
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;
218 case CoreItemFactory.NUMBER:
219 newState = new DecimalType(value);
221 case NUMBER_DIMENSIONLESS:
222 case NUMBER_TEMPERATURE:
226 newState = new QuantityType<>(Double.parseDouble(value), unit);
229 case CoreItemFactory.STRING:
230 newState = new StringType(value);
236 if (newState != null) {
237 updateState(channel, newState);
239 logger.warn("Data for unknown typeName '{}' or unknown unit received", typeName);
241 } catch (NumberFormatException e) {
242 logger.warn("NumberFormatException: {}", ((e.getMessage() != null) ? e.getMessage() : ""));
246 logger.warn("Data for not intialized ChannelId '{}' received", i);
251 private void parseAndJsonInit(String html) {
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();
263 } catch (JsonParseException | IllegalStateException | ClassCastException e) {
264 logger.warn("Invalid JSON data will be ignored: '{}'", html.replace(",,", ","));
268 private void parseAndInit(String html) {
269 String[] daqdesc = html.split("\\n");
270 List<Channel> channelList = new ArrayList<>();
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);
278 channelList.add(channel);
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");
287 if (!"reserved".equals(label)) {
288 String channel = toLowerCamelCase(replaceUmlaut(label));
289 label = label.substring(0, 1).toUpperCase() + label.substring(1);
291 String unitStr = ((param.length == 1) || param[1].isBlank()) ? "" : param[1].trim();
292 Unit<?> unit = guessUnit(unitStr);
294 boolean channelInitialized = channels.containsValue(channel);
295 if (!channelInitialized) {
298 String type = types.get(i);
303 if ("boolean".equals(type)) {
304 itemType = CoreItemFactory.SWITCH;
306 } else if ("integer".equals(type)) {
307 itemType = guessItemType(unit);
310 pattern += " %unit%";
312 } else if ("float".equals(type)) {
313 itemType = guessItemType(unit);
316 pattern += " %unit%";
318 } else if ("string".equals(type)) {
319 itemType = CoreItemFactory.STRING;
322 if (unitStr.isBlank()) {
323 itemType = CoreItemFactory.STRING;
326 itemType = guessItemType(unit);
329 pattern += " %unit%";
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);
346 "Supported Channel: Idx: '{}', Name: '{}'/'{}', Type: '{}'/'{}', Unit: '{}', Pattern '{}' ",
347 String.format("%03d", i), label, channel, type, itemType, unitStr, pattern);
351 ThingBuilder thingBuilder = editThing();
352 thingBuilder.withChannels(channelList);
353 updateThing(thingBuilder.build());
354 channelsInitialized = true;
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);
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);
374 private static String replaceUmlaut(String input) {
375 // replace all lower Umlauts
376 String output = input.replace("ü", "ue").replace("ö", "oe").replace("ä", "ae").replace("ß", "ss");
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");
382 // now replace all the other capital umlaute
383 output = output.replace("Ü", "UE").replace("Ö", "OE").replace("Ä", "AE");
388 private String toLowerCamelCase(String input) {
389 char delimiter = ' ';
390 String output = input.replace("´", "").replaceAll("[^\\w]", String.valueOf(delimiter));
392 StringBuilder builder = new StringBuilder();
393 boolean nextCharLow = true;
395 for (int i = 0; i < output.length(); i++) {
396 char currentChar = output.charAt(i);
397 if (delimiter == currentChar) {
399 } else if (nextCharLow) {
400 builder.append(Character.toLowerCase(currentChar));
402 builder.append(Character.toUpperCase(currentChar));
406 return builder.toString();
409 private @Nullable String sendGetRequest(String url, String... params) {
410 String errorReason = "";
411 String req = "http://" + config.hostname + url;
413 if (!config.key.isBlank()) {
414 req += "?key=" + config.key;
417 for (int i = 0; i < params.length; i++) {
418 if ((i == 0) && config.key.isBlank()) {
426 Request request = httpClient.newRequest(req);
427 request.method(HttpMethod.GET).timeout(30, TimeUnit.SECONDS).header(HttpHeader.ACCEPT_ENCODING, "gzip");
430 ContentResponse contentResponse = request.send();
431 if (HttpStatus.OK_200 == contentResponse.getStatus()) {
432 if (!this.getThing().getStatus().equals(ThingStatus.ONLINE)) {
433 updateStatus(ThingStatus.ONLINE);
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);
445 // PARSET_URL via return
448 } catch (IllegalArgumentException e) {
449 errorReason = String.format("IllegalArgumentException: %s",
450 ((e.getMessage() != null) ? e.getMessage() : ""));
453 errorReason = String.format("Guntamatic request failed with %d: %s", contentResponse.getStatus(),
454 ((contentResponse.getReason() != null) ? contentResponse.getReason() : ""));
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() : ""));
464 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, errorReason);
468 private void pollGuntamatic() {
469 if (!channelsInitialized) {
470 if (!config.key.isBlank()) {
471 sendGetRequest(DAQEXTDESC_URL);
473 sendGetRequest(DAQDESC_URL);
475 sendGetRequest(DAQDATA_URL);
480 public void initialize() {
481 config = getConfigAs(GuntamaticConfiguration.class);
482 if (config.hostname.isBlank()) {
483 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid hostname configuration");
485 updateStatus(ThingStatus.UNKNOWN);
486 pollingFuture = scheduler.scheduleWithFixedDelay(this::pollGuntamatic, 1, config.refreshInterval,
492 public void dispose() {
493 final ScheduledFuture<?> job = pollingFuture;
496 pollingFuture = null;
498 channelsInitialized = false;