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.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;
77 import com.google.gson.Gson;
78 import com.google.gson.GsonBuilder;
79 import com.google.gson.JsonSyntaxException;
82 * The {@link VenstarThermostatHandler} is responsible for handling commands, which are
83 * sent to one of the channels.
85 * @author William Welliver - Initial contribution
86 * @author Dan Cunningham - Migration to Jetty, annotations and various improvements
89 public class VenstarThermostatHandler extends ConfigStatusThingHandler {
91 private static final int TIMEOUT_SECONDS = 30;
92 private static final int UPDATE_AFTER_COMMAND_SECONDS = 2;
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;
101 private final HttpClient httpClient;
102 private final Gson gson;
104 // Venstar Thermostats are most commonly installed in the US, so start with a reasonable default.
105 private Unit<Temperature> unitSystem = ImperialUnits.FAHRENHEIT;
107 public VenstarThermostatHandler(Thing thing) {
109 httpClient = new HttpClient(new SslContextFactory(true));
110 gson = new GsonBuilder().registerTypeAdapter(VenstarSystemState.class, new VenstarSystemStateSerializer())
111 .registerTypeAdapter(VenstarSystemMode.class, new VenstarSystemModeSerializer()).create();
113 log.trace("VenstarThermostatHandler for thing {}", getThing().getUID());
116 @SuppressWarnings("null") // compiler does not see conf.refresh == null check
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());
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());
133 if (config.refresh == null || config.refresh < 10) {
134 log.warn("refresh is too small: {}", config.refresh);
136 status.add(ConfigStatusMessage.Builder.error(CONFIG_REFRESH).withMessageKeySuffix(REFRESH_INVALID)
137 .withArguments(CONFIG_REFRESH).build());
143 public void handleCommand(ChannelUID channelUID, Command command) {
145 if (getThing().getStatus() != ThingStatus.ONLINE) {
146 log.debug("Controller is NOT ONLINE and is not responding to commands");
151 if (command instanceof RefreshType) {
152 log.debug("Refresh command requested for {}", channelUID);
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());
172 value = VenstarSystemMode.fromInt(((DecimalType) command).intValue());
174 log.debug("Setting system mode to {}", value);
175 setSystemMode(value);
176 updateIfChanged(CHANNEL_SYSTEM_MODE_RAW, new StringType("" + value));
178 startUpdatesTask(UPDATE_AFTER_COMMAND_SECONDS);
183 public void dispose() {
185 if (httpClient.isStarted()) {
188 } catch (Exception e) {
189 log.debug("Could not stop HttpClient", e);
195 public void initialize() {
199 protected void goOnline() {
200 if (getThing().getStatus() != ThingStatus.ONLINE) {
201 updateStatus(ThingStatus.ONLINE);
205 protected void goOffline(ThingStatusDetail detail, String reason) {
206 if (getThing().getStatus() != ThingStatus.OFFLINE) {
207 updateStatus(ThingStatus.OFFLINE, detail, reason);
211 @SuppressWarnings("null") // compiler does not see new URL(url) as never being null
212 private void connect() {
214 VenstarThermostatConfiguration config = getConfigAs(VenstarThermostatConfiguration.class);
216 baseURL = new URL(config.url);
217 if (!httpClient.isStarted()) {
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;
226 } catch (Exception e) {
227 log.debug("Could not conntect to URL {}", config.url, e);
228 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
233 * Start the poller after an initial delay
235 * @param initialDelay
237 private synchronized void startUpdatesTask(int initialDelay) {
239 updatesTask = scheduler.scheduleWithFixedDelay(this::updateData, initialDelay, refresh, TimeUnit.SECONDS);
245 @SuppressWarnings("null")
246 private void stopUpdateTasks() {
247 Future<?> localUpdatesTask = updatesTask;
248 if (isFutureValid(localUpdatesTask)) {
249 localUpdatesTask.cancel(false);
253 private boolean isFutureValid(@Nullable Future<?> future) {
254 return future != null && !future.isCancelled();
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);
264 return UnDefType.UNDEF;
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);
274 return UnDefType.UNDEF;
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);
284 return UnDefType.UNDEF;
287 private void setCoolingSetpoint(int cool) {
288 int heat = getHeatingSetpoint().intValue();
289 VenstarSystemMode mode = getSystemMode();
290 updateThermostat(heat, cool, mode);
293 private void setSystemMode(VenstarSystemMode mode) {
294 int cool = getCoolingSetpoint().intValue();
295 int heat = getHeatingSetpoint().intValue();
296 updateThermostat(heat, cool, mode);
299 private void setHeatingSetpoint(int heat) {
300 int cool = getCoolingSetpoint().intValue();
301 VenstarSystemMode mode = getSystemMode();
302 updateThermostat(heat, cool, mode);
305 private QuantityType<Temperature> getCoolingSetpoint() {
306 return new QuantityType<Temperature>(infoData.getCooltemp(), unitSystem);
309 private QuantityType<Temperature> getHeatingSetpoint() {
310 return new QuantityType<Temperature>(infoData.getHeattemp(), unitSystem);
313 private VenstarSystemState getSystemState() {
314 return infoData.getState();
317 private VenstarSystemMode getSystemMode() {
318 return infoData.getMode();
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);
325 params.put("heattemp", String.valueOf(heat));
328 params.put("cooltemp", String.valueOf(cool));
330 params.put("mode", "" + mode.mode());
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);
339 log.debug("Failed to update thermostat: {}", res.getReason());
340 goOffline(ThingStatusDetail.COMMUNICATION_ERROR, "Thermostat update failed: " + res.getReason());
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");
351 private void updateData() {
353 Future<?> localUpdatesTask = updatesTask;
354 String response = getData("/query/sensors");
355 if (!isFutureValid(localUpdatesTask)) {
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());
364 response = getData("/query/info");
365 if (!isFutureValid(localUpdatesTask)) {
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()));
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");
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);
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;
403 log.warn("Thermostat returned unknown unit system type: {}", tempunits);
407 private String getData(String path) throws VenstarAuthenticationException, VenstarCommunicationException {
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);
417 private String postData(String path, Map<String, String> params)
418 throws VenstarAuthenticationException, VenstarCommunicationException {
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);
430 private String sendRequest(Request request) throws VenstarAuthenticationException, VenstarCommunicationException {
431 log.trace("sendRequest: requesting {}", request.getURI());
433 ContentResponse response = request.send();
434 log.trace("Response code {}", response.getStatus());
435 if (response.getStatus() == 401) {
436 throw new VenstarAuthenticationException();
439 if (response.getStatus() != 200) {
440 throw new VenstarCommunicationException(
441 "Error communitcating with thermostat. Error Code: " + response.getStatus());
443 String content = response.getContentAsString();
444 log.trace("sendRequest: response {}", content);
446 } catch (InterruptedException | TimeoutException | ExecutionException e) {
447 throw new VenstarCommunicationException(e);
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;
457 return new QuantityType<U>(new BigDecimal(command.toString()), defaultUnit);
460 protected DecimalType commandToDecimalType(Command command) {
461 if (command instanceof DecimalType) {
462 return (DecimalType) command;
464 return new DecimalType(new BigDecimal(command.toString()));
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();
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);
480 @SuppressWarnings("serial")
481 private class VenstarAuthenticationException extends Exception {
482 public VenstarAuthenticationException() {
483 super("Invalid Credentials");
487 @SuppressWarnings("serial")
488 private class VenstarCommunicationException extends Exception {
489 public VenstarCommunicationException(Exception e) {
493 public VenstarCommunicationException(String message) {