2 * Copyright (c) 2010-2020 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.venstarthermostat.internal.handler;
15 import static org.openhab.binding.venstarthermostat.internal.VenstarThermostatBindingConstants.*;
16 import static org.openhab.core.library.unit.SIUnits.CELSIUS;
18 import java.math.BigDecimal;
19 import java.math.RoundingMode;
20 import java.net.MalformedURLException;
21 import java.net.URISyntaxException;
24 import java.util.concurrent.ExecutionException;
25 import java.util.concurrent.Future;
26 import java.util.concurrent.TimeUnit;
27 import java.util.concurrent.TimeoutException;
29 import javax.measure.Quantity;
30 import javax.measure.Unit;
31 import javax.measure.quantity.Dimensionless;
32 import javax.measure.quantity.Temperature;
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;
71 import com.google.gson.Gson;
72 import com.google.gson.GsonBuilder;
73 import com.google.gson.JsonSyntaxException;
76 * The {@link VenstarThermostatHandler} is responsible for handling commands, which are
77 * sent to one of the channels.
79 * @author William Welliver - Initial contribution
80 * @author Dan Cunningham - Migration to Jetty, annotations and various improvements
83 public class VenstarThermostatHandler extends ConfigStatusThingHandler {
85 private static final int TIMEOUT_SECONDS = 30;
86 private static final int UPDATE_AFTER_COMMAND_SECONDS = 2;
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;
95 private final HttpClient httpClient;
96 private final Gson gson;
98 // Venstar Thermostats are most commonly installed in the US, so start with a reasonable default.
99 private Unit<Temperature> unitSystem = ImperialUnits.FAHRENHEIT;
101 public VenstarThermostatHandler(Thing thing) {
103 httpClient = new HttpClient(new SslContextFactory(true));
104 gson = new GsonBuilder().registerTypeAdapter(VenstarSystemState.class, new VenstarSystemStateSerializer())
105 .registerTypeAdapter(VenstarSystemMode.class, new VenstarSystemModeSerializer()).create();
107 log.trace("VenstarThermostatHandler for thing {}", getThing().getUID());
110 @SuppressWarnings("null") // compiler does not see conf.refresh == null check
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());
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());
127 if (config.refresh == null || config.refresh < 10) {
128 log.warn("refresh is too small: {}", config.refresh);
130 status.add(ConfigStatusMessage.Builder.error(CONFIG_REFRESH).withMessageKeySuffix(REFRESH_INVALID)
131 .withArguments(CONFIG_REFRESH).build());
137 public void handleCommand(ChannelUID channelUID, Command command) {
139 if (getThing().getStatus() != ThingStatus.ONLINE) {
140 log.debug("Controller is NOT ONLINE and is not responding to commands");
145 if (command instanceof RefreshType) {
146 log.debug("Refresh command requested for {}", channelUID);
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());
166 value = VenstarSystemMode.fromInt(((DecimalType) command).intValue());
168 log.debug("Setting system mode to {}", value);
169 setSystemMode(value);
170 updateIfChanged(CHANNEL_SYSTEM_MODE_RAW, new StringType("" + value));
172 startUpdatesTask(UPDATE_AFTER_COMMAND_SECONDS);
177 public void dispose() {
179 if (httpClient.isStarted()) {
182 } catch (Exception e) {
183 log.debug("Could not stop HttpClient", e);
189 public void initialize() {
193 protected void goOnline() {
194 if (getThing().getStatus() != ThingStatus.ONLINE) {
195 updateStatus(ThingStatus.ONLINE);
199 protected void goOffline(ThingStatusDetail detail, String reason) {
200 if (getThing().getStatus() != ThingStatus.OFFLINE) {
201 updateStatus(ThingStatus.OFFLINE, detail, reason);
205 @SuppressWarnings("null") // compiler does not see new URL(url) as never being null
206 private void connect() {
208 VenstarThermostatConfiguration config = getConfigAs(VenstarThermostatConfiguration.class);
210 baseURL = new URL(config.url);
211 if (!httpClient.isStarted()) {
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;
220 } catch (Exception e) {
221 log.debug("Could not conntect to URL {}", config.url, e);
222 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
227 * Start the poller after an initial delay
229 * @param initialDelay
231 private synchronized void startUpdatesTask(int initialDelay) {
233 updatesTask = scheduler.scheduleWithFixedDelay(this::updateData, initialDelay, refresh, TimeUnit.SECONDS);
239 @SuppressWarnings("null")
240 private void stopUpdateTasks() {
241 Future<?> localUpdatesTask = updatesTask;
242 if (isFutureValid(localUpdatesTask)) {
243 localUpdatesTask.cancel(false);
247 private boolean isFutureValid(@Nullable Future<?> future) {
248 return future != null && !future.isCancelled();
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);
258 return UnDefType.UNDEF;
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);
268 return UnDefType.UNDEF;
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);
278 return UnDefType.UNDEF;
281 private void setCoolingSetpoint(int cool) {
282 int heat = getHeatingSetpoint().intValue();
283 VenstarSystemMode mode = getSystemMode();
284 updateThermostat(heat, cool, mode);
287 private void setSystemMode(VenstarSystemMode mode) {
288 int cool = getCoolingSetpoint().intValue();
289 int heat = getHeatingSetpoint().intValue();
290 updateThermostat(heat, cool, mode);
293 private void setHeatingSetpoint(int heat) {
294 int cool = getCoolingSetpoint().intValue();
295 VenstarSystemMode mode = getSystemMode();
296 updateThermostat(heat, cool, mode);
299 private QuantityType<Temperature> getCoolingSetpoint() {
300 return new QuantityType<Temperature>(infoData.getCooltemp(), unitSystem);
303 private QuantityType<Temperature> getHeatingSetpoint() {
304 return new QuantityType<Temperature>(infoData.getHeattemp(), unitSystem);
307 private VenstarSystemState getSystemState() {
308 return infoData.getState();
311 private VenstarSystemMode getSystemMode() {
312 return infoData.getMode();
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);
319 params.put("heattemp", String.valueOf(heat));
322 params.put("cooltemp", String.valueOf(cool));
324 params.put("mode", "" + mode.mode());
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);
333 log.debug("Failed to update thermostat: {}", res.getReason());
334 goOffline(ThingStatusDetail.COMMUNICATION_ERROR, "Thermostat update failed: " + res.getReason());
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");
345 private void updateData() {
347 Future<?> localUpdatesTask = updatesTask;
348 String response = getData("/query/sensors");
349 if (!isFutureValid(localUpdatesTask)) {
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());
358 response = getData("/query/info");
359 if (!isFutureValid(localUpdatesTask)) {
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()));
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");
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);
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;
397 log.warn("Thermostat returned unknown unit system type: {}", tempunits);
401 private String getData(String path) throws VenstarAuthenticationException, VenstarCommunicationException {
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);
411 private String postData(String path, Map<String, String> params)
412 throws VenstarAuthenticationException, VenstarCommunicationException {
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);
424 private String sendRequest(Request request) throws VenstarAuthenticationException, VenstarCommunicationException {
425 log.trace("sendRequest: requesting {}", request.getURI());
427 ContentResponse response = request.send();
428 log.trace("Response code {}", response.getStatus());
429 if (response.getStatus() == 401) {
430 throw new VenstarAuthenticationException();
433 if (response.getStatus() != 200) {
434 throw new VenstarCommunicationException(
435 "Error communitcating with thermostat. Error Code: " + response.getStatus());
437 String content = response.getContentAsString();
438 log.trace("sendRequest: response {}", content);
440 } catch (InterruptedException | TimeoutException | ExecutionException e) {
441 throw new VenstarCommunicationException(e);
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;
451 return new QuantityType<U>(new BigDecimal(command.toString()), defaultUnit);
454 protected DecimalType commandToDecimalType(Command command) {
455 if (command instanceof DecimalType) {
456 return (DecimalType) command;
458 return new DecimalType(new BigDecimal(command.toString()));
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();
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);
474 @SuppressWarnings("serial")
475 private class VenstarAuthenticationException extends Exception {
476 public VenstarAuthenticationException() {
477 super("Invalid Credentials");
481 @SuppressWarnings("serial")
482 private class VenstarCommunicationException extends Exception {
483 public VenstarCommunicationException(Exception e) {
487 public VenstarCommunicationException(String message) {