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.model.VenstarAwayMode;
51 import org.openhab.binding.venstarthermostat.internal.model.VenstarAwayModeSerializer;
52 import org.openhab.binding.venstarthermostat.internal.model.VenstarInfoData;
53 import org.openhab.binding.venstarthermostat.internal.model.VenstarResponse;
54 import org.openhab.binding.venstarthermostat.internal.model.VenstarSensor;
55 import org.openhab.binding.venstarthermostat.internal.model.VenstarSensorData;
56 import org.openhab.binding.venstarthermostat.internal.model.VenstarSystemMode;
57 import org.openhab.binding.venstarthermostat.internal.model.VenstarSystemModeSerializer;
58 import org.openhab.binding.venstarthermostat.internal.model.VenstarSystemState;
59 import org.openhab.binding.venstarthermostat.internal.model.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());
120 @SuppressWarnings("null") // compiler does not see conf.refresh == null check
122 public Collection<ConfigStatusMessage> getConfigStatus() {
123 Collection<ConfigStatusMessage> status = new ArrayList<>();
124 VenstarThermostatConfiguration config = getConfigAs(VenstarThermostatConfiguration.class);
125 if (config.username.isBlank()) {
126 log.warn("username is empty");
127 status.add(ConfigStatusMessage.Builder.error(CONFIG_USERNAME).withMessageKeySuffix(EMPTY_INVALID)
128 .withArguments(CONFIG_USERNAME).build());
131 if (config.password.isBlank()) {
132 log.warn("password is empty");
133 status.add(ConfigStatusMessage.Builder.error(CONFIG_PASSWORD).withMessageKeySuffix(EMPTY_INVALID)
134 .withArguments(CONFIG_PASSWORD).build());
137 if (config.refresh == null || config.refresh < 10) {
138 log.warn("refresh is too small: {}", config.refresh);
140 status.add(ConfigStatusMessage.Builder.error(CONFIG_REFRESH).withMessageKeySuffix(REFRESH_INVALID)
141 .withArguments(CONFIG_REFRESH).build());
147 public void handleCommand(ChannelUID channelUID, Command command) {
149 if (getThing().getStatus() != ThingStatus.ONLINE) {
150 log.debug("Controller is NOT ONLINE and is not responding to commands");
155 if (command instanceof RefreshType) {
156 log.debug("Refresh command requested for {}", channelUID);
160 stateMap.remove(channelUID.getAsString());
161 if (channelUID.getId().equals(CHANNEL_HEATING_SETPOINT)) {
162 QuantityType<Temperature> quantity = commandToQuantityType(command, unitSystem);
163 int value = quantityToRoundedTemperature(quantity, unitSystem).intValue();
164 log.debug("Setting heating setpoint to {}", value);
165 setHeatingSetpoint(value);
166 } else if (channelUID.getId().equals(CHANNEL_COOLING_SETPOINT)) {
167 QuantityType<Temperature> quantity = commandToQuantityType(command, unitSystem);
168 int value = quantityToRoundedTemperature(quantity, unitSystem).intValue();
169 log.debug("Setting cooling setpoint to {}", value);
170 setCoolingSetpoint(value);
171 } else if (channelUID.getId().equals(CHANNEL_SYSTEM_MODE)) {
172 VenstarSystemMode value;
173 if (command instanceof StringType) {
174 value = VenstarSystemMode.valueOf(((StringType) command).toString().toUpperCase());
176 value = VenstarSystemMode.fromInt(((DecimalType) command).intValue());
178 log.debug("Setting system mode to {}", value);
179 setSystemMode(value);
180 updateIfChanged(CHANNEL_SYSTEM_MODE_RAW, new StringType(value.toString()));
181 } else if (channelUID.getId().equals(CHANNEL_AWAY_MODE)) {
182 VenstarAwayMode value;
183 if (command instanceof StringType) {
184 value = VenstarAwayMode.valueOf(((StringType) command).toString().toUpperCase());
186 value = VenstarAwayMode.fromInt(((DecimalType) command).intValue());
188 log.debug("Setting away mode to {}", value);
190 updateIfChanged(CHANNEL_AWAY_MODE_RAW, new StringType(value.toString()));
192 startUpdatesTask(UPDATE_AFTER_COMMAND_SECONDS);
197 public void dispose() {
199 if (httpClient.isStarted()) {
202 } catch (Exception e) {
203 log.debug("Could not stop HttpClient", e);
209 public void initialize() {
213 protected void goOnline() {
214 if (getThing().getStatus() != ThingStatus.ONLINE) {
215 updateStatus(ThingStatus.ONLINE);
219 protected void goOffline(ThingStatusDetail detail, String reason) {
220 if (getThing().getStatus() != ThingStatus.OFFLINE) {
221 updateStatus(ThingStatus.OFFLINE, detail, reason);
225 @SuppressWarnings("null") // compiler does not see new URL(url) as never being null
226 private void connect() {
228 VenstarThermostatConfiguration config = getConfigAs(VenstarThermostatConfiguration.class);
230 baseURL = new URL(config.url);
231 if (!httpClient.isStarted()) {
234 httpClient.getAuthenticationStore().clearAuthentications();
235 httpClient.getAuthenticationStore().clearAuthenticationResults();
236 httpClient.getAuthenticationStore().addAuthentication(
237 new DigestAuthentication(baseURL.toURI(), "thermostat", config.username, config.password));
238 refresh = config.refresh;
240 } catch (Exception e) {
241 log.debug("Could not conntect to URL {}", config.url, e);
242 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
247 * Start the poller after an initial delay
249 * @param initialDelay
251 private synchronized void startUpdatesTask(int initialDelay) {
253 updatesTask = scheduler.scheduleWithFixedDelay(this::updateData, initialDelay, refresh, TimeUnit.SECONDS);
259 @SuppressWarnings("null")
260 private void stopUpdateTasks() {
261 Future<?> localUpdatesTask = updatesTask;
262 if (isFutureValid(localUpdatesTask)) {
263 localUpdatesTask.cancel(false);
267 private boolean isFutureValid(@Nullable Future<?> future) {
268 return future != null && !future.isCancelled();
271 private State getTemperature() {
272 Optional<VenstarSensor> optSensor = sensorData.stream()
273 .filter(sensor -> sensor.getName().equalsIgnoreCase("Thermostat")).findAny();
274 if (optSensor.isPresent()) {
275 return new QuantityType<Temperature>(optSensor.get().getTemp(), unitSystem);
278 return UnDefType.UNDEF;
281 private State getHumidity() {
282 Optional<VenstarSensor> optSensor = sensorData.stream()
283 .filter(sensor -> sensor.getName().equalsIgnoreCase("Thermostat")).findAny();
284 if (optSensor.isPresent()) {
285 return new QuantityType<Dimensionless>(optSensor.get().getHum(), Units.PERCENT);
288 return UnDefType.UNDEF;
291 private State getOutdoorTemperature() {
292 Optional<VenstarSensor> optSensor = sensorData.stream()
293 .filter(sensor -> sensor.getName().equalsIgnoreCase("Outdoor")).findAny();
294 if (optSensor.isPresent()) {
295 return new QuantityType<Temperature>(optSensor.get().getTemp(), unitSystem);
298 return UnDefType.UNDEF;
301 private void setCoolingSetpoint(int cool) {
302 int heat = getHeatingSetpoint().intValue();
303 VenstarSystemMode mode = getSystemMode();
304 updateControls(heat, cool, mode);
307 private void setSystemMode(VenstarSystemMode mode) {
308 int cool = getCoolingSetpoint().intValue();
309 int heat = getHeatingSetpoint().intValue();
310 updateControls(heat, cool, mode);
313 private void setHeatingSetpoint(int heat) {
314 int cool = getCoolingSetpoint().intValue();
315 VenstarSystemMode mode = getSystemMode();
316 updateControls(heat, cool, mode);
319 private void setAwayMode(VenstarAwayMode away) {
320 updateSettings(away);
323 private QuantityType<Temperature> getCoolingSetpoint() {
324 return new QuantityType<Temperature>(infoData.getCooltemp(), unitSystem);
327 private QuantityType<Temperature> getHeatingSetpoint() {
328 return new QuantityType<Temperature>(infoData.getHeattemp(), unitSystem);
331 private VenstarSystemState getSystemState() {
332 return infoData.getState();
335 private VenstarSystemMode getSystemMode() {
336 return infoData.getMode();
339 private VenstarAwayMode getAwayMode() {
340 return infoData.getAway();
343 private void updateSettings(VenstarAwayMode away) {
344 // this function corresponds to the thermostat local API POST /settings instruction
345 // the function can be expanded with other parameters which are changed via POST /settings
346 Map<String, String> params = new HashMap<>();
347 params.put("away", String.valueOf(away.mode()));
348 VenstarResponse res = updateThermostat("/settings", params);
350 log.debug("Updated thermostat");
351 // update our local copy until the next refresh occurs
352 infoData.setAwayMode(away);
353 // add other parameters here in the same way
357 private void updateControls(int heat, int cool, VenstarSystemMode mode) {
358 // this function corresponds to the thermostat local API POST /control instruction
359 // the function can be expanded with other parameters which are changed via POST /control
360 Map<String, String> params = new HashMap<>();
362 params.put("heattemp", String.valueOf(heat));
365 params.put("cooltemp", String.valueOf(cool));
367 params.put("mode", String.valueOf(mode.mode()));
368 VenstarResponse res = updateThermostat("/control", params);
370 log.debug("Updated thermostat");
371 // update our local copy until the next refresh occurs
372 infoData.setCooltemp(cool);
373 infoData.setHeattemp(heat);
374 infoData.setMode(mode);
375 // add other parameters here in the same way
380 * Function to send data to the thermostat and update the Thing state if there is an error
384 * @return VenstarResponse object or null if there was an error
386 private @Nullable VenstarResponse updateThermostat(String path, Map<String, String> params) {
388 String result = postData(path, params);
389 VenstarResponse res = gson.fromJson(result, VenstarResponse.class);
390 if (res != null && res.isSuccess()) {
393 String reason = res == null ? "invalid response" : res.getReason();
394 log.debug("Failed to update thermostat: {}", reason);
395 goOffline(ThingStatusDetail.COMMUNICATION_ERROR, reason);
397 } catch (VenstarCommunicationException | JsonSyntaxException e) {
398 log.debug("Unable to fetch info data", e);
399 String message = e.getMessage();
400 goOffline(ThingStatusDetail.COMMUNICATION_ERROR, message != null ? message : "");
401 } catch (VenstarAuthenticationException e) {
402 goOffline(ThingStatusDetail.CONFIGURATION_ERROR, "Authorization Failed");
407 private void updateData() {
409 Future<?> localUpdatesTask = updatesTask;
410 String response = getData("/query/sensors");
411 if (!isFutureValid(localUpdatesTask)) {
414 VenstarSensorData res = gson.fromJson(response, VenstarSensorData.class);
415 sensorData = res.getSensors();
416 updateIfChanged(CHANNEL_TEMPERATURE, getTemperature());
417 updateIfChanged(CHANNEL_EXTERNAL_TEMPERATURE, getOutdoorTemperature());
418 updateIfChanged(CHANNEL_HUMIDITY, getHumidity());
420 response = getData("/query/info");
421 if (!isFutureValid(localUpdatesTask)) {
424 infoData = Objects.requireNonNull(gson.fromJson(response, VenstarInfoData.class));
425 updateUnits(infoData);
426 updateIfChanged(CHANNEL_HEATING_SETPOINT, getHeatingSetpoint());
427 updateIfChanged(CHANNEL_COOLING_SETPOINT, getCoolingSetpoint());
428 updateIfChanged(CHANNEL_SYSTEM_STATE, new StringType(getSystemState().stateName()));
429 updateIfChanged(CHANNEL_SYSTEM_MODE, new StringType(getSystemMode().modeName()));
430 updateIfChanged(CHANNEL_SYSTEM_STATE_RAW, new DecimalType(getSystemState().state()));
431 updateIfChanged(CHANNEL_SYSTEM_MODE_RAW, new DecimalType(getSystemMode().mode()));
432 updateIfChanged(CHANNEL_AWAY_MODE, new StringType(getAwayMode().modeName()));
433 updateIfChanged(CHANNEL_AWAY_MODE_RAW, new DecimalType(getAwayMode().mode()));
436 } catch (VenstarCommunicationException | JsonSyntaxException e) {
437 log.debug("Unable to fetch info data", e);
438 String message = e.getMessage();
439 goOffline(ThingStatusDetail.COMMUNICATION_ERROR, message != null ? message : "");
440 } catch (VenstarAuthenticationException e) {
441 goOffline(ThingStatusDetail.CONFIGURATION_ERROR, "Authorization Failed");
445 private void updateIfChanged(String channelID, State state) {
446 ChannelUID channelUID = new ChannelUID(getThing().getUID(), channelID);
447 State oldState = stateMap.put(channelUID.toString(), state);
448 if (!state.equals(oldState)) {
449 log.trace("updating channel {} with state {} (old state {})", channelUID, state, oldState);
450 updateState(channelUID, state);
454 private void updateUnits(VenstarInfoData infoData) {
455 int tempunits = infoData.getTempunits();
456 if (tempunits == 0) {
457 unitSystem = ImperialUnits.FAHRENHEIT;
458 } else if (tempunits == 1) {
459 unitSystem = SIUnits.CELSIUS;
461 log.warn("Thermostat returned unknown unit system type: {}", tempunits);
465 private String getData(String path) throws VenstarAuthenticationException, VenstarCommunicationException {
467 URL getURL = new URL(baseURL, path);
468 Request request = httpClient.newRequest(getURL.toURI()).timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS);
469 return sendRequest(request);
470 } catch (MalformedURLException | URISyntaxException e) {
471 throw new VenstarCommunicationException(e);
475 private String postData(String path, Map<String, String> params)
476 throws VenstarAuthenticationException, VenstarCommunicationException {
478 URL postURL = new URL(baseURL, path);
479 Request request = httpClient.newRequest(postURL.toURI()).timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
480 .method(HttpMethod.POST);
481 params.forEach(request::param);
482 return sendRequest(request);
483 } catch (MalformedURLException | URISyntaxException e) {
484 throw new VenstarCommunicationException(e);
488 private String sendRequest(Request request) throws VenstarAuthenticationException, VenstarCommunicationException {
489 log.trace("sendRequest: requesting {}", request.getURI());
491 ContentResponse response = request.send();
492 log.trace("Response code {}", response.getStatus());
493 if (response.getStatus() == 401) {
494 throw new VenstarAuthenticationException();
497 if (response.getStatus() != 200) {
498 throw new VenstarCommunicationException(
499 "Error communicating with thermostat. Error Code: " + response.getStatus());
501 String content = response.getContentAsString();
502 log.trace("sendRequest: response {}", content);
504 } catch (InterruptedException | TimeoutException | ExecutionException e) {
505 throw new VenstarCommunicationException(e);
510 @SuppressWarnings("unchecked")
511 protected <U extends Quantity<U>> QuantityType<U> commandToQuantityType(Command command, Unit<U> defaultUnit) {
512 if (command instanceof QuantityType) {
513 return (QuantityType<U>) command;
515 return new QuantityType<U>(new BigDecimal(command.toString()), defaultUnit);
518 protected DecimalType commandToDecimalType(Command command) {
519 if (command instanceof DecimalType) {
520 return (DecimalType) command;
522 return new DecimalType(new BigDecimal(command.toString()));
525 private BigDecimal quantityToRoundedTemperature(QuantityType<Temperature> quantity, Unit<Temperature> unit)
526 throws IllegalArgumentException {
527 QuantityType<Temperature> temparatureQuantity = quantity.toUnit(unit);
528 if (temparatureQuantity == null) {
529 return quantity.toBigDecimal();
532 BigDecimal value = temparatureQuantity.toBigDecimal();
533 BigDecimal increment = CELSIUS == unit ? new BigDecimal("0.5") : new BigDecimal("1");
534 BigDecimal divisor = value.divide(increment, 0, RoundingMode.HALF_UP);
535 return divisor.multiply(increment);
538 @SuppressWarnings("serial")
539 private class VenstarAuthenticationException extends Exception {
540 public VenstarAuthenticationException() {
541 super("Invalid Credentials");
545 @SuppressWarnings("serial")
546 private class VenstarCommunicationException extends Exception {
547 public VenstarCommunicationException(Exception e) {
551 public VenstarCommunicationException(String message) {