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