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