]> git.basschouten.com Git - openhab-addons.git/blob
3e21f20f64f93389ade51a2656a70f27538cdaa9
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.venstarthermostat.internal.handler;
14
15 import static org.openhab.binding.venstarthermostat.internal.VenstarThermostatBindingConstants.*;
16 import static org.openhab.core.library.unit.SIUnits.CELSIUS;
17
18 import java.math.BigDecimal;
19 import java.math.RoundingMode;
20 import java.net.MalformedURLException;
21 import java.net.URISyntaxException;
22 import java.net.URL;
23 import java.util.ArrayList;
24 import java.util.Collection;
25 import java.util.Collections;
26 import java.util.HashMap;
27 import java.util.List;
28 import java.util.Map;
29 import java.util.Objects;
30 import java.util.Optional;
31 import java.util.concurrent.ExecutionException;
32 import java.util.concurrent.Future;
33 import java.util.concurrent.TimeUnit;
34 import java.util.concurrent.TimeoutException;
35
36 import javax.measure.Quantity;
37 import javax.measure.Unit;
38 import javax.measure.quantity.Dimensionless;
39 import javax.measure.quantity.Temperature;
40
41 import org.eclipse.jdt.annotation.NonNullByDefault;
42 import org.eclipse.jdt.annotation.Nullable;
43 import org.eclipse.jetty.client.HttpClient;
44 import org.eclipse.jetty.client.api.ContentResponse;
45 import org.eclipse.jetty.client.api.Request;
46 import org.eclipse.jetty.client.util.DigestAuthentication;
47 import org.eclipse.jetty.http.HttpMethod;
48 import org.eclipse.jetty.util.ssl.SslContextFactory;
49 import org.openhab.binding.venstarthermostat.internal.VenstarThermostatConfiguration;
50 import org.openhab.binding.venstarthermostat.internal.model.VenstarAwayMode;
51 import org.openhab.binding.venstarthermostat.internal.model.VenstarAwayModeSerializer;
52 import org.openhab.binding.venstarthermostat.internal.model.VenstarInfoData;
53 import org.openhab.binding.venstarthermostat.internal.model.VenstarResponse;
54 import org.openhab.binding.venstarthermostat.internal.model.VenstarSensor;
55 import org.openhab.binding.venstarthermostat.internal.model.VenstarSensorData;
56 import org.openhab.binding.venstarthermostat.internal.model.VenstarSystemMode;
57 import org.openhab.binding.venstarthermostat.internal.model.VenstarSystemModeSerializer;
58 import org.openhab.binding.venstarthermostat.internal.model.VenstarSystemState;
59 import org.openhab.binding.venstarthermostat.internal.model.VenstarSystemStateSerializer;
60 import org.openhab.core.config.core.status.ConfigStatusMessage;
61 import org.openhab.core.library.types.DecimalType;
62 import org.openhab.core.library.types.QuantityType;
63 import org.openhab.core.library.types.StringType;
64 import org.openhab.core.library.unit.ImperialUnits;
65 import org.openhab.core.library.unit.SIUnits;
66 import org.openhab.core.library.unit.Units;
67 import org.openhab.core.thing.ChannelUID;
68 import org.openhab.core.thing.Thing;
69 import org.openhab.core.thing.ThingStatus;
70 import org.openhab.core.thing.ThingStatusDetail;
71 import org.openhab.core.thing.binding.ConfigStatusThingHandler;
72 import org.openhab.core.types.Command;
73 import org.openhab.core.types.RefreshType;
74 import org.openhab.core.types.State;
75 import org.openhab.core.types.UnDefType;
76 import org.slf4j.Logger;
77 import org.slf4j.LoggerFactory;
78
79 import com.google.gson.Gson;
80 import com.google.gson.GsonBuilder;
81 import com.google.gson.JsonSyntaxException;
82
83 /**
84  * The {@link VenstarThermostatHandler} is responsible for handling commands, which are
85  * sent to one of the channels.
86  *
87  * @author William Welliver - Initial contribution
88  * @author Dan Cunningham - Migration to Jetty, annotations and various improvements
89  * @author Matthew Davies - added code to include away mode in binding
90  */
91 @NonNullByDefault
92 public class VenstarThermostatHandler extends ConfigStatusThingHandler {
93
94     private static final int TIMEOUT_SECONDS = 30;
95     private static final int UPDATE_AFTER_COMMAND_SECONDS = 2;
96
97     private Logger log = LoggerFactory.getLogger(VenstarThermostatHandler.class);
98     private List<VenstarSensor> sensorData = new ArrayList<>();
99     private VenstarInfoData infoData = new VenstarInfoData();
100     private Map<String, State> stateMap = Collections.synchronizedMap(new HashMap<>());
101     private @Nullable Future<?> updatesTask;
102     private @Nullable URL baseURL;
103     private int refresh;
104     private final HttpClient httpClient;
105     private final Gson gson;
106
107     // Venstar Thermostats are most commonly installed in the US, so start with a reasonable default.
108     private Unit<Temperature> unitSystem = ImperialUnits.FAHRENHEIT;
109
110     public VenstarThermostatHandler(Thing thing) {
111         super(thing);
112         httpClient = new HttpClient(new SslContextFactory.Client(true));
113         gson = new GsonBuilder().registerTypeAdapter(VenstarSystemState.class, new VenstarSystemStateSerializer())
114                 .registerTypeAdapter(VenstarSystemMode.class, new VenstarSystemModeSerializer())
115                 .registerTypeAdapter(VenstarAwayMode.class, new VenstarAwayModeSerializer()).create();
116
117         log.trace("VenstarThermostatHandler for thing {}", getThing().getUID());
118     }
119
120     @SuppressWarnings("null") // compiler does not see conf.refresh == null check
121     @Override
122     public Collection<ConfigStatusMessage> getConfigStatus() {
123         Collection<ConfigStatusMessage> status = new ArrayList<>();
124         VenstarThermostatConfiguration config = getConfigAs(VenstarThermostatConfiguration.class);
125         if (config.username.isBlank()) {
126             log.warn("username is empty");
127             status.add(ConfigStatusMessage.Builder.error(CONFIG_USERNAME).withMessageKeySuffix(EMPTY_INVALID)
128                     .withArguments(CONFIG_USERNAME).build());
129         }
130
131         if (config.password.isBlank()) {
132             log.warn("password is empty");
133             status.add(ConfigStatusMessage.Builder.error(CONFIG_PASSWORD).withMessageKeySuffix(EMPTY_INVALID)
134                     .withArguments(CONFIG_PASSWORD).build());
135         }
136
137         if (config.refresh == null || config.refresh < 10) {
138             log.warn("refresh is too small: {}", config.refresh);
139
140             status.add(ConfigStatusMessage.Builder.error(CONFIG_REFRESH).withMessageKeySuffix(REFRESH_INVALID)
141                     .withArguments(CONFIG_REFRESH).build());
142         }
143         return status;
144     }
145
146     @Override
147     public void handleCommand(ChannelUID channelUID, Command command) {
148
149         if (getThing().getStatus() != ThingStatus.ONLINE) {
150             log.debug("Controller is NOT ONLINE and is not responding to commands");
151             return;
152         }
153
154         stopUpdateTasks();
155         if (command instanceof RefreshType) {
156             log.debug("Refresh command requested for {}", channelUID);
157             stateMap.clear();
158             startUpdatesTask(0);
159         } else {
160             stateMap.remove(channelUID.getAsString());
161             if (channelUID.getId().equals(CHANNEL_HEATING_SETPOINT)) {
162                 QuantityType<Temperature> quantity = commandToQuantityType(command, unitSystem);
163                 int value = quantityToRoundedTemperature(quantity, unitSystem).intValue();
164                 log.debug("Setting heating setpoint to {}", value);
165                 setHeatingSetpoint(value);
166             } else if (channelUID.getId().equals(CHANNEL_COOLING_SETPOINT)) {
167                 QuantityType<Temperature> quantity = commandToQuantityType(command, unitSystem);
168                 int value = quantityToRoundedTemperature(quantity, unitSystem).intValue();
169                 log.debug("Setting cooling setpoint to {}", value);
170                 setCoolingSetpoint(value);
171             } else if (channelUID.getId().equals(CHANNEL_SYSTEM_MODE)) {
172                 VenstarSystemMode value;
173                 if (command instanceof StringType) {
174                     value = VenstarSystemMode.valueOf(((StringType) command).toString().toUpperCase());
175                 } else {
176                     value = VenstarSystemMode.fromInt(((DecimalType) command).intValue());
177                 }
178                 log.debug("Setting system mode to  {}", value);
179                 setSystemMode(value);
180                 updateIfChanged(CHANNEL_SYSTEM_MODE_RAW, new StringType(value.toString()));
181             } else if (channelUID.getId().equals(CHANNEL_AWAY_MODE)) {
182                 VenstarAwayMode value;
183                 if (command instanceof StringType) {
184                     value = VenstarAwayMode.valueOf(((StringType) command).toString().toUpperCase());
185                 } else {
186                     value = VenstarAwayMode.fromInt(((DecimalType) command).intValue());
187                 }
188                 log.debug("Setting away mode to  {}", value);
189                 setAwayMode(value);
190                 updateIfChanged(CHANNEL_AWAY_MODE_RAW, new StringType(value.toString()));
191             }
192             startUpdatesTask(UPDATE_AFTER_COMMAND_SECONDS);
193         }
194     }
195
196     @Override
197     public void dispose() {
198         stopUpdateTasks();
199         if (httpClient.isStarted()) {
200             try {
201                 httpClient.stop();
202             } catch (Exception e) {
203                 log.debug("Could not stop HttpClient", e);
204             }
205         }
206     }
207
208     @Override
209     public void initialize() {
210         connect();
211     }
212
213     protected void goOnline() {
214         if (getThing().getStatus() != ThingStatus.ONLINE) {
215             updateStatus(ThingStatus.ONLINE);
216         }
217     }
218
219     protected void goOffline(ThingStatusDetail detail, String reason) {
220         if (getThing().getStatus() != ThingStatus.OFFLINE) {
221             updateStatus(ThingStatus.OFFLINE, detail, reason);
222         }
223     }
224
225     @SuppressWarnings("null") // compiler does not see new URL(url) as never being null
226     private void connect() {
227         stopUpdateTasks();
228         VenstarThermostatConfiguration config = getConfigAs(VenstarThermostatConfiguration.class);
229         try {
230             baseURL = new URL(config.url);
231             if (!httpClient.isStarted()) {
232                 httpClient.start();
233             }
234             httpClient.getAuthenticationStore().clearAuthentications();
235             httpClient.getAuthenticationStore().clearAuthenticationResults();
236             httpClient.getAuthenticationStore().addAuthentication(
237                     new DigestAuthentication(baseURL.toURI(), "thermostat", config.username, config.password));
238             refresh = config.refresh;
239             startUpdatesTask(0);
240         } catch (Exception e) {
241             log.debug("Could not conntect to URL  {}", config.url, e);
242             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
243         }
244     }
245
246     /**
247      * Start the poller after an initial delay
248      *
249      * @param initialDelay
250      */
251     private synchronized void startUpdatesTask(int initialDelay) {
252         stopUpdateTasks();
253         updatesTask = scheduler.scheduleWithFixedDelay(this::updateData, initialDelay, refresh, TimeUnit.SECONDS);
254     }
255
256     /**
257      * Stop the poller
258      */
259     @SuppressWarnings("null")
260     private void stopUpdateTasks() {
261         Future<?> localUpdatesTask = updatesTask;
262         if (isFutureValid(localUpdatesTask)) {
263             localUpdatesTask.cancel(false);
264         }
265     }
266
267     private boolean isFutureValid(@Nullable Future<?> future) {
268         return future != null && !future.isCancelled();
269     }
270
271     private State getTemperature() {
272         Optional<VenstarSensor> optSensor = sensorData.stream()
273                 .filter(sensor -> sensor.getName().equalsIgnoreCase("Thermostat")).findAny();
274         if (optSensor.isPresent()) {
275             return new QuantityType<Temperature>(optSensor.get().getTemp(), unitSystem);
276         }
277
278         return UnDefType.UNDEF;
279     }
280
281     private State getHumidity() {
282         Optional<VenstarSensor> optSensor = sensorData.stream()
283                 .filter(sensor -> sensor.getName().equalsIgnoreCase("Thermostat")).findAny();
284         if (optSensor.isPresent()) {
285             return new QuantityType<Dimensionless>(optSensor.get().getHum(), Units.PERCENT);
286         }
287
288         return UnDefType.UNDEF;
289     }
290
291     private State getOutdoorTemperature() {
292         Optional<VenstarSensor> optSensor = sensorData.stream()
293                 .filter(sensor -> sensor.getName().equalsIgnoreCase("Outdoor")).findAny();
294         if (optSensor.isPresent()) {
295             return new QuantityType<Temperature>(optSensor.get().getTemp(), unitSystem);
296         }
297
298         return UnDefType.UNDEF;
299     }
300
301     private void setCoolingSetpoint(int cool) {
302         int heat = getHeatingSetpoint().intValue();
303         VenstarSystemMode mode = getSystemMode();
304         updateControls(heat, cool, mode);
305     }
306
307     private void setSystemMode(VenstarSystemMode mode) {
308         int cool = getCoolingSetpoint().intValue();
309         int heat = getHeatingSetpoint().intValue();
310         updateControls(heat, cool, mode);
311     }
312
313     private void setHeatingSetpoint(int heat) {
314         int cool = getCoolingSetpoint().intValue();
315         VenstarSystemMode mode = getSystemMode();
316         updateControls(heat, cool, mode);
317     }
318
319     private void setAwayMode(VenstarAwayMode away) {
320         updateSettings(away);
321     }
322
323     private QuantityType<Temperature> getCoolingSetpoint() {
324         return new QuantityType<Temperature>(infoData.getCooltemp(), unitSystem);
325     }
326
327     private QuantityType<Temperature> getHeatingSetpoint() {
328         return new QuantityType<Temperature>(infoData.getHeattemp(), unitSystem);
329     }
330
331     private VenstarSystemState getSystemState() {
332         return infoData.getState();
333     }
334
335     private VenstarSystemMode getSystemMode() {
336         return infoData.getMode();
337     }
338
339     private VenstarAwayMode getAwayMode() {
340         return infoData.getAway();
341     }
342
343     private void updateSettings(VenstarAwayMode away) {
344         // this function corresponds to the thermostat local API POST /settings instruction
345         // the function can be expanded with other parameters which are changed via POST /settings
346         Map<String, String> params = new HashMap<>();
347         params.put("away", String.valueOf(away.mode()));
348         VenstarResponse res = updateThermostat("/settings", params);
349         if (res != null) {
350             log.debug("Updated thermostat");
351             // update our local copy until the next refresh occurs
352             infoData.setAwayMode(away);
353             // add other parameters here in the same way
354         }
355     }
356
357     private void updateControls(int heat, int cool, VenstarSystemMode mode) {
358         // this function corresponds to the thermostat local API POST /control instruction
359         // the function can be expanded with other parameters which are changed via POST /control
360         Map<String, String> params = new HashMap<>();
361         if (heat > 0) {
362             params.put("heattemp", String.valueOf(heat));
363         }
364         if (cool > 0) {
365             params.put("cooltemp", String.valueOf(cool));
366         }
367         params.put("mode", String.valueOf(mode.mode()));
368         VenstarResponse res = updateThermostat("/control", params);
369         if (res != null) {
370             log.debug("Updated thermostat");
371             // update our local copy until the next refresh occurs
372             infoData.setCooltemp(cool);
373             infoData.setHeattemp(heat);
374             infoData.setMode(mode);
375             // add other parameters here in the same way
376         }
377     }
378
379     /**
380      * Function to send data to the thermostat and update the Thing state if there is an error
381      *
382      * @param path
383      * @param params
384      * @return VenstarResponse object or null if there was an error
385      */
386     private @Nullable VenstarResponse updateThermostat(String path, Map<String, String> params) {
387         try {
388             String result = postData(path, params);
389             VenstarResponse res = gson.fromJson(result, VenstarResponse.class);
390             if (res != null && res.isSuccess()) {
391                 return res;
392             } else {
393                 String reason = res == null ? "invalid response" : res.getReason();
394                 log.debug("Failed to update thermostat: {}", reason);
395                 goOffline(ThingStatusDetail.COMMUNICATION_ERROR, reason);
396             }
397         } catch (VenstarCommunicationException | JsonSyntaxException e) {
398             log.debug("Unable to fetch info data", e);
399             String message = e.getMessage();
400             goOffline(ThingStatusDetail.COMMUNICATION_ERROR, message != null ? message : "");
401         } catch (VenstarAuthenticationException e) {
402             goOffline(ThingStatusDetail.CONFIGURATION_ERROR, "Authorization Failed");
403         }
404         return null;
405     }
406
407     private void updateData() {
408         try {
409             Future<?> localUpdatesTask = updatesTask;
410             String response = getData("/query/sensors");
411             if (!isFutureValid(localUpdatesTask)) {
412                 return;
413             }
414             VenstarSensorData res = gson.fromJson(response, VenstarSensorData.class);
415             sensorData = res.getSensors();
416             updateIfChanged(CHANNEL_TEMPERATURE, getTemperature());
417             updateIfChanged(CHANNEL_EXTERNAL_TEMPERATURE, getOutdoorTemperature());
418             updateIfChanged(CHANNEL_HUMIDITY, getHumidity());
419
420             response = getData("/query/info");
421             if (!isFutureValid(localUpdatesTask)) {
422                 return;
423             }
424             infoData = Objects.requireNonNull(gson.fromJson(response, VenstarInfoData.class));
425             updateUnits(infoData);
426             updateIfChanged(CHANNEL_HEATING_SETPOINT, getHeatingSetpoint());
427             updateIfChanged(CHANNEL_COOLING_SETPOINT, getCoolingSetpoint());
428             updateIfChanged(CHANNEL_SYSTEM_STATE, new StringType(getSystemState().stateName()));
429             updateIfChanged(CHANNEL_SYSTEM_MODE, new StringType(getSystemMode().modeName()));
430             updateIfChanged(CHANNEL_SYSTEM_STATE_RAW, new DecimalType(getSystemState().state()));
431             updateIfChanged(CHANNEL_SYSTEM_MODE_RAW, new DecimalType(getSystemMode().mode()));
432             updateIfChanged(CHANNEL_AWAY_MODE, new StringType(getAwayMode().modeName()));
433             updateIfChanged(CHANNEL_AWAY_MODE_RAW, new DecimalType(getAwayMode().mode()));
434
435             goOnline();
436         } catch (VenstarCommunicationException | JsonSyntaxException e) {
437             log.debug("Unable to fetch info data", e);
438             String message = e.getMessage();
439             goOffline(ThingStatusDetail.COMMUNICATION_ERROR, message != null ? message : "");
440         } catch (VenstarAuthenticationException e) {
441             goOffline(ThingStatusDetail.CONFIGURATION_ERROR, "Authorization Failed");
442         }
443     }
444
445     private void updateIfChanged(String channelID, State state) {
446         ChannelUID channelUID = new ChannelUID(getThing().getUID(), channelID);
447         State oldState = stateMap.put(channelUID.toString(), state);
448         if (!state.equals(oldState)) {
449             log.trace("updating channel {} with state {} (old state {})", channelUID, state, oldState);
450             updateState(channelUID, state);
451         }
452     }
453
454     private void updateUnits(VenstarInfoData infoData) {
455         int tempunits = infoData.getTempunits();
456         if (tempunits == 0) {
457             unitSystem = ImperialUnits.FAHRENHEIT;
458         } else if (tempunits == 1) {
459             unitSystem = SIUnits.CELSIUS;
460         } else {
461             log.warn("Thermostat returned unknown unit system type: {}", tempunits);
462         }
463     }
464
465     private String getData(String path) throws VenstarAuthenticationException, VenstarCommunicationException {
466         try {
467             URL getURL = new URL(baseURL, path);
468             Request request = httpClient.newRequest(getURL.toURI()).timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS);
469             return sendRequest(request);
470         } catch (MalformedURLException | URISyntaxException e) {
471             throw new VenstarCommunicationException(e);
472         }
473     }
474
475     private String postData(String path, Map<String, String> params)
476             throws VenstarAuthenticationException, VenstarCommunicationException {
477         try {
478             URL postURL = new URL(baseURL, path);
479             Request request = httpClient.newRequest(postURL.toURI()).timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
480                     .method(HttpMethod.POST);
481             params.forEach(request::param);
482             return sendRequest(request);
483         } catch (MalformedURLException | URISyntaxException e) {
484             throw new VenstarCommunicationException(e);
485         }
486     }
487
488     private String sendRequest(Request request) throws VenstarAuthenticationException, VenstarCommunicationException {
489         log.trace("sendRequest: requesting {}", request.getURI());
490         try {
491             ContentResponse response = request.send();
492             log.trace("Response code {}", response.getStatus());
493             if (response.getStatus() == 401) {
494                 throw new VenstarAuthenticationException();
495             }
496
497             if (response.getStatus() != 200) {
498                 throw new VenstarCommunicationException(
499                         "Error communicating with thermostat. Error Code: " + response.getStatus());
500             }
501             String content = response.getContentAsString();
502             log.trace("sendRequest: response {}", content);
503             return content;
504         } catch (InterruptedException | TimeoutException | ExecutionException e) {
505             throw new VenstarCommunicationException(e);
506
507         }
508     }
509
510     @SuppressWarnings("unchecked")
511     protected <U extends Quantity<U>> QuantityType<U> commandToQuantityType(Command command, Unit<U> defaultUnit) {
512         if (command instanceof QuantityType) {
513             return (QuantityType<U>) command;
514         }
515         return new QuantityType<U>(new BigDecimal(command.toString()), defaultUnit);
516     }
517
518     protected DecimalType commandToDecimalType(Command command) {
519         if (command instanceof DecimalType) {
520             return (DecimalType) command;
521         }
522         return new DecimalType(new BigDecimal(command.toString()));
523     }
524
525     private BigDecimal quantityToRoundedTemperature(QuantityType<Temperature> quantity, Unit<Temperature> unit)
526             throws IllegalArgumentException {
527         QuantityType<Temperature> temparatureQuantity = quantity.toUnit(unit);
528         if (temparatureQuantity == null) {
529             return quantity.toBigDecimal();
530         }
531
532         BigDecimal value = temparatureQuantity.toBigDecimal();
533         BigDecimal increment = CELSIUS == unit ? new BigDecimal("0.5") : new BigDecimal("1");
534         BigDecimal divisor = value.divide(increment, 0, RoundingMode.HALF_UP);
535         return divisor.multiply(increment);
536     }
537
538     @SuppressWarnings("serial")
539     private class VenstarAuthenticationException extends Exception {
540         public VenstarAuthenticationException() {
541             super("Invalid Credentials");
542         }
543     }
544
545     @SuppressWarnings("serial")
546     private class VenstarCommunicationException extends Exception {
547         public VenstarCommunicationException(Exception e) {
548             super(e);
549         }
550
551         public VenstarCommunicationException(String message) {
552             super(message);
553         }
554     }
555 }