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.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;
61 import com.google.gson.JsonArray;
62 import com.google.gson.JsonObject;
63 import com.google.gson.JsonParseException;
64 import com.google.gson.JsonParser;
67 * The {@link GuntamaticHandler} is responsible for handling commands, which are
68 * sent to one of the channels.
70 * @author Weger Michael - Initial contribution
73 public class GuntamaticHandler extends BaseThingHandler {
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";
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);
86 private final Logger logger = LoggerFactory.getLogger(GuntamaticHandler.class);
87 private final HttpClient httpClient;
89 private @Nullable ScheduledFuture<?> pollingFuture = null;
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<>();
98 public GuntamaticHandler(Thing thing, HttpClient httpClient,
99 GuntamaticChannelTypeProvider guntamaticChannelTypeProvider) {
101 this.httpClient = httpClient;
102 this.guntamaticChannelTypeProvider = guntamaticChannelTypeProvider;
106 public void handleCommand(ChannelUID channelUID, Command command) {
107 if (!(command instanceof RefreshType)) {
108 if (!config.key.isBlank()) {
110 String channelID = channelUID.getId();
112 case CHANNEL_CONTROLBOILERAPPROVAL:
113 param = getThing().getProperties().get(PARAMETER_BOILERAPPROVAL);
115 case CHANNEL_CONTROLPROGRAM:
116 param = getThing().getProperties().get(PARAMETER_PROGRAM);
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));
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));
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));
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);
151 logger.warn("A 'key' needs to be configured in order to control the Guntamatic Heating System");
156 private void parseAndUpdate(String html) {
157 String[] daqdata = html.split("\\n");
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();
169 State newState = null;
170 if (typeName != null) {
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;
181 case CoreItemFactory.NUMBER:
182 newState = new DecimalType(value);
184 case NUMBER_DIMENSIONLESS:
185 case NUMBER_TEMPERATURE:
189 newState = new QuantityType<>(Double.parseDouble(value), unit);
192 case CoreItemFactory.STRING:
193 newState = new StringType(value);
199 if (newState != null) {
200 updateState(channel, newState);
202 logger.warn("Data for unknown typeName '{}' or unknown unit received", typeName);
204 } catch (NumberFormatException e) {
205 logger.warn("NumberFormatException: {}", ((e.getMessage() != null) ? e.getMessage() : ""));
209 logger.warn("Data for not intialized ChannelId '{}' received", i);
214 private void parseAndJsonInit(String html) {
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();
226 } catch (JsonParseException | IllegalStateException | ClassCastException e) {
227 logger.warn("Invalid JSON data will be ignored: '{}'", html.replace(",,", ","));
231 private void parseAndInit(String html) {
232 String[] daqdesc = html.split("\\n");
233 List<Channel> channelList = new ArrayList<>();
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);
240 channelList.add(channel);
244 for (int i = 0; i < daqdesc.length; i++) {
245 String[] param = daqdesc[i].split(";");
246 String label = param[0].replace("C02", "CO2");
248 if (!"reserved".equals(label)) {
249 String channel = toLowerCamelCase(replaceUmlaut(label));
250 label = label.substring(0, 1).toUpperCase() + label.substring(1);
252 String unitStr = ((param.length == 1) || param[1].isBlank()) ? "" : param[1].trim();
253 Unit<?> unit = guessUnit(unitStr);
255 boolean channelInitialized = channels.containsValue(channel);
256 if (!channelInitialized) {
259 String type = types.get(i);
264 if ("boolean".equals(type)) {
265 itemType = CoreItemFactory.SWITCH;
267 } else if ("integer".equals(type)) {
268 itemType = guessItemType(unit);
271 pattern += " %unit%";
273 } else if ("float".equals(type)) {
274 itemType = guessItemType(unit);
277 pattern += " %unit%";
279 } else if ("string".equals(type)) {
280 itemType = CoreItemFactory.STRING;
283 if (unitStr.isBlank()) {
284 itemType = CoreItemFactory.STRING;
287 itemType = guessItemType(unit);
290 pattern += " %unit%";
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);
307 "Supported Channel: Idx: '{}', Name: '{}'/'{}', Type: '{}'/'{}', Unit: '{}', Pattern '{}' ",
308 String.format("%03d", i), label, channel, type, itemType, unitStr, pattern);
312 ThingBuilder thingBuilder = editThing();
313 thingBuilder.withChannels(channelList);
314 updateThing(thingBuilder.build());
315 channelsInitialized = true;
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);
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);
335 private static String replaceUmlaut(String input) {
336 // replace all lower Umlauts
337 String output = input.replace("ü", "ue").replace("ö", "oe").replace("ä", "ae").replace("ß", "ss");
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");
343 // now replace all the other capital umlaute
344 output = output.replace("Ü", "UE").replace("Ö", "OE").replace("Ä", "AE");
349 private String toLowerCamelCase(String input) {
350 char delimiter = ' ';
351 String output = input.replace("´", "").replaceAll("[^\\w]", String.valueOf(delimiter));
353 StringBuilder builder = new StringBuilder();
354 boolean nextCharLow = true;
356 for (int i = 0; i < output.length(); i++) {
357 char currentChar = output.charAt(i);
358 if (delimiter == currentChar) {
360 } else if (nextCharLow) {
361 builder.append(Character.toLowerCase(currentChar));
363 builder.append(Character.toUpperCase(currentChar));
367 return builder.toString();
370 private @Nullable String sendGetRequest(String url, String... params) {
371 String errorReason = "";
372 String req = "http://" + config.hostname + url;
374 if (!config.key.isBlank()) {
375 req += "?key=" + config.key;
378 for (int i = 0; i < params.length; i++) {
379 if ((i == 0) && config.key.isBlank()) {
387 Request request = httpClient.newRequest(req);
388 request.method(HttpMethod.GET).timeout(30, TimeUnit.SECONDS).header(HttpHeader.ACCEPT_ENCODING, "gzip");
391 ContentResponse contentResponse = request.send();
392 if (HttpStatus.OK_200 == contentResponse.getStatus()) {
393 if (!this.getThing().getStatus().equals(ThingStatus.ONLINE)) {
394 updateStatus(ThingStatus.ONLINE);
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);
405 // PARSET_URL via return
407 } catch (IllegalArgumentException e) {
408 errorReason = String.format("IllegalArgumentException: %s",
409 ((e.getMessage() != null) ? e.getMessage() : ""));
412 errorReason = String.format("Guntamatic request failed with %d: %s", contentResponse.getStatus(),
413 ((contentResponse.getReason() != null) ? contentResponse.getReason() : ""));
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() : ""));
423 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, errorReason);
427 private void pollGuntamatic() {
428 if (!channelsInitialized) {
429 if (!config.key.isBlank()) {
430 sendGetRequest(DAQEXTDESC_URL);
432 sendGetRequest(DAQDESC_URL);
434 sendGetRequest(DAQDATA_URL);
439 public void initialize() {
440 config = getConfigAs(GuntamaticConfiguration.class);
441 if (config.hostname.isBlank()) {
442 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid hostname configuration");
444 updateStatus(ThingStatus.UNKNOWN);
445 pollingFuture = scheduler.scheduleWithFixedDelay(this::pollGuntamatic, 1, config.refreshInterval,
451 public void dispose() {
452 final ScheduledFuture<?> job = pollingFuture;
455 pollingFuture = null;
457 channelsInitialized = false;