2 * Copyright (c) 2010-2021 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;
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;
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;
36 import javax.measure.Quantity;
37 import javax.measure.Unit;
38 import javax.measure.quantity.Dimensionless;
39 import javax.measure.quantity.Temperature;
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;
79 import com.google.gson.Gson;
80 import com.google.gson.GsonBuilder;
81 import com.google.gson.JsonSyntaxException;
84 * The {@link VenstarThermostatHandler} is responsible for handling commands, which are
85 * sent to one of the channels.
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
92 public class VenstarThermostatHandler extends ConfigStatusThingHandler {
94 private static final int TIMEOUT_SECONDS = 30;
95 private static final int UPDATE_AFTER_COMMAND_SECONDS = 2;
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;
104 private final HttpClient httpClient;
105 private final Gson gson;
107 // Venstar Thermostats are most commonly installed in the US, so start with a reasonable default.
108 private Unit<Temperature> unitSystem = ImperialUnits.FAHRENHEIT;
110 public VenstarThermostatHandler(Thing 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();
117 log.trace("VenstarThermostatHandler for thing {}", getThing().getUID());
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());
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());
136 if (config.refresh < 10) {
137 log.warn("refresh is too small: {}", config.refresh);
139 status.add(ConfigStatusMessage.Builder.error(CONFIG_REFRESH).withMessageKeySuffix(REFRESH_INVALID)
140 .withArguments(CONFIG_REFRESH).build());
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");
153 if (command instanceof RefreshType) {
154 log.debug("Refresh command requested for {}", channelUID);
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());
174 value = VenstarSystemMode.fromInt(((DecimalType) command).intValue());
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());
184 value = VenstarAwayMode.fromInt(((DecimalType) command).intValue());
186 log.debug("Setting away mode to {}", value);
188 updateIfChanged(CHANNEL_AWAY_MODE_RAW, new StringType(value.toString()));
190 startUpdatesTask(UPDATE_AFTER_COMMAND_SECONDS);
195 public void dispose() {
197 if (httpClient.isStarted()) {
200 } catch (Exception e) {
201 log.debug("Could not stop HttpClient", e);
207 public void initialize() {
211 protected void goOnline() {
212 if (getThing().getStatus() != ThingStatus.ONLINE) {
213 updateStatus(ThingStatus.ONLINE);
217 protected void goOffline(ThingStatusDetail detail, String reason) {
218 if (getThing().getStatus() != ThingStatus.OFFLINE) {
219 updateStatus(ThingStatus.OFFLINE, detail, reason);
223 @SuppressWarnings("null") // compiler does not see new URL(url) as never being null
224 private void connect() {
226 VenstarThermostatConfiguration config = getConfigAs(VenstarThermostatConfiguration.class);
228 baseURL = new URL(config.url);
229 if (!httpClient.isStarted()) {
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;
238 } catch (Exception e) {
239 log.debug("Could not conntect to URL {}", config.url, e);
240 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
245 * Start the poller after an initial delay
247 * @param initialDelay
249 private synchronized void startUpdatesTask(int initialDelay) {
251 updatesTask = scheduler.scheduleWithFixedDelay(this::updateData, initialDelay, refresh, TimeUnit.SECONDS);
257 @SuppressWarnings("null")
258 private void stopUpdateTasks() {
259 Future<?> localUpdatesTask = updatesTask;
260 if (isFutureValid(localUpdatesTask)) {
261 localUpdatesTask.cancel(false);
265 private boolean isFutureValid(@Nullable Future<?> future) {
266 return future != null && !future.isCancelled();
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);
276 return UnDefType.UNDEF;
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);
286 return UnDefType.UNDEF;
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);
296 return UnDefType.UNDEF;
299 private void setCoolingSetpoint(double cool) {
300 double heat = getHeatingSetpoint().doubleValue();
301 VenstarSystemMode mode = getSystemMode();
302 updateControls(heat, cool, mode);
305 private void setSystemMode(VenstarSystemMode mode) {
306 double cool = getCoolingSetpoint().doubleValue();
307 double heat = getHeatingSetpoint().doubleValue();
308 updateControls(heat, cool, mode);
311 private void setHeatingSetpoint(double heat) {
312 double cool = getCoolingSetpoint().doubleValue();
313 VenstarSystemMode mode = getSystemMode();
314 updateControls(heat, cool, mode);
317 private void setAwayMode(VenstarAwayMode away) {
318 updateSettings(away);
321 private QuantityType<Temperature> getCoolingSetpoint() {
322 return new QuantityType<Temperature>(infoData.getCooltemp(), unitSystem);
325 private QuantityType<Temperature> getHeatingSetpoint() {
326 return new QuantityType<Temperature>(infoData.getHeattemp(), unitSystem);
329 private VenstarSystemState getSystemState() {
330 return infoData.getState();
333 private VenstarSystemMode getSystemMode() {
334 return infoData.getMode();
337 private VenstarAwayMode getAwayMode() {
338 return infoData.getAway();
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);
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
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<>();
360 params.put("heattemp", String.valueOf(heat));
363 params.put("cooltemp", String.valueOf(cool));
365 params.put("mode", String.valueOf(mode.mode()));
366 VenstarResponse res = updateThermostat("/control", params);
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
378 * Function to send data to the thermostat and update the Thing state if there is an error
382 * @return VenstarResponse object or null if there was an error
384 private @Nullable VenstarResponse updateThermostat(String path, Map<String, String> params) {
386 String result = postData(path, params);
387 VenstarResponse res = gson.fromJson(result, VenstarResponse.class);
388 if (res != null && res.isSuccess()) {
391 String reason = res == null ? "invalid response" : res.getReason();
392 log.debug("Failed to update thermostat: {}", reason);
393 goOffline(ThingStatusDetail.COMMUNICATION_ERROR, reason);
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");
405 private void updateData() {
407 Future<?> localUpdatesTask = updatesTask;
408 String response = getData("/query/sensors");
409 if (!isFutureValid(localUpdatesTask)) {
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());
418 response = getData("/query/info");
419 if (!isFutureValid(localUpdatesTask)) {
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()));
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");
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);
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;
459 log.warn("Thermostat returned unknown unit system type: {}", tempunits);
463 private String getData(String path) throws VenstarAuthenticationException, VenstarCommunicationException {
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);
473 private String postData(String path, Map<String, String> params)
474 throws VenstarAuthenticationException, VenstarCommunicationException {
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);
486 private String sendRequest(Request request) throws VenstarAuthenticationException, VenstarCommunicationException {
487 log.trace("sendRequest: requesting {}", request.getURI());
489 ContentResponse response = request.send();
490 log.trace("Response code {}", response.getStatus());
491 if (response.getStatus() == 401) {
492 throw new VenstarAuthenticationException();
495 if (response.getStatus() != 200) {
496 throw new VenstarCommunicationException(
497 "Error communicating with thermostat. Error Code: " + response.getStatus());
499 String content = response.getContentAsString();
500 log.trace("sendRequest: response {}", content);
502 } catch (InterruptedException | TimeoutException | ExecutionException e) {
503 throw new VenstarCommunicationException(e);
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;
512 return new QuantityType<U>(new BigDecimal(command.toString()), defaultUnit);
515 protected DecimalType commandToDecimalType(Command command) {
516 if (command instanceof DecimalType) {
517 return (DecimalType) command;
519 return new DecimalType(new BigDecimal(command.toString()));
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();
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);
535 @SuppressWarnings("serial")
536 private class VenstarAuthenticationException extends Exception {
537 public VenstarAuthenticationException() {
538 super("Invalid Credentials");
542 @SuppressWarnings("serial")
543 private class VenstarCommunicationException extends Exception {
544 public VenstarCommunicationException(Exception e) {
548 public VenstarCommunicationException(String message) {