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.radiothermostat.internal.handler;
15 import static org.openhab.binding.radiothermostat.internal.RadioThermostatBindingConstants.*;
17 import java.math.BigDecimal;
18 import java.text.NumberFormat;
19 import java.text.ParseException;
20 import java.time.ZonedDateTime;
21 import java.util.ArrayList;
22 import java.util.Collection;
23 import java.util.Collections;
24 import java.util.List;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
28 import javax.measure.quantity.Temperature;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.client.HttpClient;
33 import org.openhab.binding.radiothermostat.internal.RadioThermostatConfiguration;
34 import org.openhab.binding.radiothermostat.internal.RadioThermostatStateDescriptionProvider;
35 import org.openhab.binding.radiothermostat.internal.RadioThermostatThingActions;
36 import org.openhab.binding.radiothermostat.internal.communication.RadioThermostatConnector;
37 import org.openhab.binding.radiothermostat.internal.communication.RadioThermostatEvent;
38 import org.openhab.binding.radiothermostat.internal.communication.RadioThermostatEventListener;
39 import org.openhab.binding.radiothermostat.internal.dto.RadioThermostatDTO;
40 import org.openhab.binding.radiothermostat.internal.dto.RadioThermostatHumidityDTO;
41 import org.openhab.binding.radiothermostat.internal.dto.RadioThermostatRuntimeDTO;
42 import org.openhab.binding.radiothermostat.internal.dto.RadioThermostatTstatDTO;
43 import org.openhab.core.library.types.DateTimeType;
44 import org.openhab.core.library.types.DecimalType;
45 import org.openhab.core.library.types.OnOffType;
46 import org.openhab.core.library.types.PointType;
47 import org.openhab.core.library.types.QuantityType;
48 import org.openhab.core.library.types.StringType;
49 import org.openhab.core.thing.Channel;
50 import org.openhab.core.thing.ChannelUID;
51 import org.openhab.core.thing.Thing;
52 import org.openhab.core.thing.ThingStatus;
53 import org.openhab.core.thing.ThingStatusDetail;
54 import org.openhab.core.thing.binding.BaseThingHandler;
55 import org.openhab.core.thing.binding.ThingHandlerService;
56 import org.openhab.core.types.Command;
57 import org.openhab.core.types.RefreshType;
58 import org.openhab.core.types.State;
59 import org.openhab.core.types.StateOption;
60 import org.openhab.core.types.UnDefType;
61 import org.slf4j.Logger;
62 import org.slf4j.LoggerFactory;
64 import com.google.gson.Gson;
67 * The {@link RadioThermostatHandler} is responsible for handling commands, which are
68 * sent to one of the channels.
70 * Based on the 'airquality' binding by Kuba Wolanin
72 * @author Michael Lobstein - Initial contribution
75 public class RadioThermostatHandler extends BaseThingHandler implements RadioThermostatEventListener {
76 private static final int DEFAULT_REFRESH_PERIOD = 2;
77 private static final int DEFAULT_LOG_REFRESH_PERIOD = 10;
79 private final RadioThermostatStateDescriptionProvider stateDescriptionProvider;
80 private final Logger logger = LoggerFactory.getLogger(RadioThermostatHandler.class);
82 private final Gson gson;
83 private final RadioThermostatConnector connector;
84 private final RadioThermostatDTO rthermData = new RadioThermostatDTO();
86 private @Nullable ScheduledFuture<?> refreshJob;
87 private @Nullable ScheduledFuture<?> logRefreshJob;
89 private int refreshPeriod = DEFAULT_REFRESH_PERIOD;
90 private int logRefreshPeriod = DEFAULT_LOG_REFRESH_PERIOD;
91 private boolean isCT80 = false;
92 private boolean disableLogs = false;
93 private String setpointCmdKeyPrefix = "t_";
95 public RadioThermostatHandler(Thing thing, RadioThermostatStateDescriptionProvider stateDescriptionProvider,
96 HttpClient httpClient) {
98 this.stateDescriptionProvider = stateDescriptionProvider;
100 connector = new RadioThermostatConnector(httpClient);
104 public void initialize() {
105 logger.debug("Initializing RadioThermostat handler.");
106 RadioThermostatConfiguration config = getConfigAs(RadioThermostatConfiguration.class);
108 final String hostName = config.hostName;
109 final Integer refresh = config.refresh;
110 final Integer logRefresh = config.logRefresh;
111 this.isCT80 = config.isCT80;
112 this.disableLogs = config.disableLogs;
114 if (hostName == null || hostName.equals("")) {
115 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
116 "Thermostat Host Name must be specified");
120 if (refresh != null) {
121 this.refreshPeriod = refresh;
124 if (logRefresh != null) {
125 this.logRefreshPeriod = logRefresh;
128 connector.setThermostatHostName(hostName);
129 connector.addEventListener(this);
131 // The setpoint mode is controlled by the name of setpoint attribute sent to the thermostat.
132 // Temporary mode uses setpoint names prefixed with "t_" while absolute mode uses "a_"
133 if (config.setpointMode.equals("absolute")) {
134 this.setpointCmdKeyPrefix = "a_";
137 // populate fan mode options based on thermostat model
138 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), FAN_MODE), getFanModeOptions());
140 // if we are not a CT-80, remove the humidity & program mode channel
142 List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
143 channels.removeIf(c -> (c.getUID().getId().equals(HUMIDITY)));
144 channels.removeIf(c -> (c.getUID().getId().equals(PROGRAM_MODE)));
145 updateThing(editThing().withChannels(channels).build());
147 startAutomaticRefresh();
148 if (!this.disableLogs || this.isCT80) {
149 startAutomaticLogRefresh();
152 updateStatus(ThingStatus.UNKNOWN);
156 public Collection<Class<? extends ThingHandlerService>> getServices() {
157 return Collections.singletonList(RadioThermostatThingActions.class);
161 * Start the job to periodically update data from the thermostat
163 private void startAutomaticRefresh() {
164 ScheduledFuture<?> refreshJob = this.refreshJob;
165 if (refreshJob == null || refreshJob.isCancelled()) {
166 Runnable runnable = () -> {
167 // send an async call to the thermostat to get the 'tstat' data
168 connector.getAsyncThermostatData(DEFAULT_RESOURCE);
172 this.refreshJob = scheduler.scheduleWithFixedDelay(runnable, 0, refreshPeriod, TimeUnit.MINUTES);
177 * Start the job to periodically update humidity and runtime date from the thermostat
179 private void startAutomaticLogRefresh() {
180 ScheduledFuture<?> logRefreshJob = this.logRefreshJob;
181 if (logRefreshJob == null || logRefreshJob.isCancelled()) {
182 Runnable runnable = () -> {
183 // Request humidity data from the thermostat if we are a CT80
185 // send an async call to the thermostat to get the humidity data
186 connector.getAsyncThermostatData(HUMIDITY_RESOURCE);
189 if (!this.disableLogs) {
190 // send an async call to the thermostat to get the runtime data
191 connector.getAsyncThermostatData(RUNTIME_RESOURCE);
195 logRefreshJob = null;
196 this.logRefreshJob = scheduler.scheduleWithFixedDelay(runnable, 1, logRefreshPeriod, TimeUnit.MINUTES);
201 public void dispose() {
202 logger.debug("Disposing the RadioThermostat handler.");
203 connector.removeEventListener(this);
205 ScheduledFuture<?> refreshJob = this.refreshJob;
206 if (refreshJob != null) {
207 refreshJob.cancel(true);
208 this.refreshJob = null;
211 ScheduledFuture<?> logRefreshJob = this.logRefreshJob;
212 if (logRefreshJob != null) {
213 logRefreshJob.cancel(true);
214 this.logRefreshJob = null;
218 public void handleRawCommand(@Nullable String rawCommand) {
219 connector.sendCommand(null, null, rawCommand);
223 public void handleCommand(ChannelUID channelUID, Command command) {
224 if (command instanceof RefreshType) {
225 updateChannel(channelUID.getId(), rthermData);
228 String cmdStr = command.toString();
229 if (cmdStr != null) {
231 // parse out an Integer from the string
232 // ie '70.5 F' becomes 70, also handles negative numbers
233 cmdInt = NumberFormat.getInstance().parse(cmdStr).intValue();
234 } catch (ParseException e) {
235 logger.debug("Command: {} -> Not an integer", cmdStr);
239 switch (channelUID.getId()) {
241 // only do if commanded mode is different than current mode
242 if (!cmdInt.equals(rthermData.getThermostatData().getMode())) {
243 connector.sendCommand("tmode", cmdStr);
245 // set the new operating mode, reset everything else,
246 // because refreshing the tstat data below is really slow.
247 rthermData.getThermostatData().setMode(cmdInt);
248 rthermData.getThermostatData().setHeatTarget(0);
249 rthermData.getThermostatData().setCoolTarget(0);
250 updateChannel(SET_POINT, rthermData);
251 rthermData.getThermostatData().setHold(0);
252 updateChannel(HOLD, rthermData);
253 rthermData.getThermostatData().setProgramMode(-1);
254 updateChannel(PROGRAM_MODE, rthermData);
256 // now just trigger a refresh of the thermost to get the new active setpoint
257 // this takes a while for the JSON request to complete (async).
258 connector.getAsyncThermostatData(DEFAULT_RESOURCE);
262 rthermData.getThermostatData().setFanMode(cmdInt);
263 connector.sendCommand("fmode", cmdStr);
266 rthermData.getThermostatData().setProgramMode(cmdInt);
267 connector.sendCommand("program_mode", cmdStr);
270 if (command instanceof OnOffType && command == OnOffType.ON) {
271 rthermData.getThermostatData().setHold(1);
272 connector.sendCommand("hold", "1");
273 } else if (command instanceof OnOffType && command == OnOffType.OFF) {
274 rthermData.getThermostatData().setHold(0);
275 connector.sendCommand("hold", "0");
279 String cmdKey = null;
280 if (rthermData.getThermostatData().getMode() == 1) {
281 cmdKey = this.setpointCmdKeyPrefix + "heat";
282 rthermData.getThermostatData().setHeatTarget(cmdInt);
283 } else if (rthermData.getThermostatData().getMode() == 2) {
284 cmdKey = this.setpointCmdKeyPrefix + "cool";
285 rthermData.getThermostatData().setCoolTarget(cmdInt);
287 // don't do anything if we are not in heat or cool mode
290 connector.sendCommand(cmdKey, cmdInt.toString());
293 logger.warn("Unsupported command: {}", command.toString());
299 * Handle a RadioThermostat event received from the listeners
301 * @param event the event received from the listeners
304 public void onNewMessageEvent(RadioThermostatEvent event) {
305 logger.debug("onNewMessageEvent: key {} = {}", event.getKey(), event.getValue());
307 String evtKey = event.getKey();
308 String evtVal = event.getValue();
310 if (KEY_ERROR.equals(evtKey)) {
311 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
312 "Error retrieving data from Thermostat ");
314 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
316 // Map the JSON response to the correct object and update appropriate channels
318 case DEFAULT_RESOURCE:
319 rthermData.setThermostatData(gson.fromJson(evtVal, RadioThermostatTstatDTO.class));
322 case HUMIDITY_RESOURCE:
323 rthermData.setHumidity(gson.fromJson(evtVal, RadioThermostatHumidityDTO.class).getHumidity());
324 updateChannel(HUMIDITY, rthermData);
326 case RUNTIME_RESOURCE:
327 rthermData.setRuntime(gson.fromJson(evtVal, RadioThermostatRuntimeDTO.class));
328 updateChannel(TODAY_HEAT_RUNTIME, rthermData);
329 updateChannel(TODAY_COOL_RUNTIME, rthermData);
330 updateChannel(YESTERDAY_HEAT_RUNTIME, rthermData);
331 updateChannel(YESTERDAY_COOL_RUNTIME, rthermData);
338 * Update the channel from the last Thermostat data retrieved
340 * @param channelId the id identifying the channel to be updated
342 private void updateChannel(String channelId, RadioThermostatDTO rthermData) {
343 if (isLinked(channelId)) {
346 value = getValue(channelId, rthermData);
347 } catch (Exception e) {
348 logger.debug("Error setting {} value", channelId.toUpperCase());
354 state = UnDefType.UNDEF;
355 } else if (value instanceof PointType) {
356 state = (PointType) value;
357 } else if (value instanceof ZonedDateTime) {
358 state = new DateTimeType((ZonedDateTime) value);
359 } else if (value instanceof QuantityType<?>) {
360 state = (QuantityType<?>) value;
361 } else if (value instanceof BigDecimal) {
362 state = new DecimalType((BigDecimal) value);
363 } else if (value instanceof Integer) {
364 state = new DecimalType(BigDecimal.valueOf(((Integer) value).longValue()));
365 } else if (value instanceof String) {
366 state = new StringType(value.toString());
367 } else if (value instanceof OnOffType) {
368 state = (OnOffType) value;
370 logger.warn("Update channel {}: Unsupported value type {}", channelId,
371 value.getClass().getSimpleName());
373 logger.debug("Update channel {} with state {} ({})", channelId, (state == null) ? "null" : state.toString(),
374 (value == null) ? "null" : value.getClass().getSimpleName());
376 // Update the channel
378 updateState(channelId, state);
384 * Update a given channelId from the thermostat data
386 * @param the channel id to be updated
387 * @param data the RadioThermostat dto
388 * @return the value to be set in the state
390 public static @Nullable Object getValue(String channelId, RadioThermostatDTO data) {
393 if (data.getThermostatData().getTemperature() != null) {
394 return new QuantityType<Temperature>(data.getThermostatData().getTemperature(),
395 API_TEMPERATURE_UNIT);
400 if (data.getHumidity() != null) {
401 return new QuantityType<>(data.getHumidity(), API_HUMIDITY_UNIT);
406 return data.getThermostatData().getMode();
408 return data.getThermostatData().getFanMode();
410 return data.getThermostatData().getProgramMode();
412 if (data.getThermostatData().getSetpoint() != 0) {
413 return new QuantityType<Temperature>(data.getThermostatData().getSetpoint(), API_TEMPERATURE_UNIT);
418 return data.getThermostatData().getOverride();
420 return OnOffType.from(data.getThermostatData().getHold() == 1);
422 return data.getThermostatData().getStatus();
424 return data.getThermostatData().getFanStatus();
426 return data.getThermostatData().getTime().getDayOfWeek();
428 return data.getThermostatData().getTime().getHour();
430 return data.getThermostatData().getTime().getMinute();
432 return data.getThermostatData().getTime().getThemostatDateTime();
433 case TODAY_HEAT_RUNTIME:
434 return new QuantityType<>(data.getRuntime().getToday().getHeatTime().getRuntime(), API_MINUTES_UNIT);
435 case TODAY_COOL_RUNTIME:
436 return new QuantityType<>(data.getRuntime().getToday().getCoolTime().getRuntime(), API_MINUTES_UNIT);
437 case YESTERDAY_HEAT_RUNTIME:
438 return new QuantityType<>(data.getRuntime().getYesterday().getHeatTime().getRuntime(),
440 case YESTERDAY_COOL_RUNTIME:
441 return new QuantityType<>(data.getRuntime().getYesterday().getCoolTime().getRuntime(),
448 * Updates all channels from rthermData
450 private void updateAllChannels() {
451 // Update all channels from rthermData
452 for (Channel channel : getThing().getChannels()) {
453 updateChannel(channel.getUID().getId(), rthermData);
458 * Build a list of fan modes based on what model thermostat is used
460 * @return list of state options for thermostat fan modes
462 private List<StateOption> getFanModeOptions() {
463 List<StateOption> fanModeOptions = new ArrayList<>();
465 fanModeOptions.add(new StateOption("0", "Auto"));
467 fanModeOptions.add(new StateOption("1", "Auto/Circulate"));
469 fanModeOptions.add(new StateOption("2", "On"));
471 return fanModeOptions;