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