2 * Copyright (c) 2010-2023 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.Calendar;
23 import java.util.Collection;
24 import java.util.Collections;
25 import java.util.List;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
29 import javax.measure.quantity.Temperature;
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.eclipse.jetty.client.HttpClient;
34 import org.openhab.binding.radiothermostat.internal.RadioThermostatConfiguration;
35 import org.openhab.binding.radiothermostat.internal.RadioThermostatStateDescriptionProvider;
36 import org.openhab.binding.radiothermostat.internal.RadioThermostatThingActions;
37 import org.openhab.binding.radiothermostat.internal.communication.RadioThermostatConnector;
38 import org.openhab.binding.radiothermostat.internal.communication.RadioThermostatEvent;
39 import org.openhab.binding.radiothermostat.internal.communication.RadioThermostatEventListener;
40 import org.openhab.binding.radiothermostat.internal.dto.RadioThermostatDTO;
41 import org.openhab.binding.radiothermostat.internal.dto.RadioThermostatHumidityDTO;
42 import org.openhab.binding.radiothermostat.internal.dto.RadioThermostatRuntimeDTO;
43 import org.openhab.binding.radiothermostat.internal.dto.RadioThermostatTstatDTO;
44 import org.openhab.binding.radiothermostat.internal.util.RadioThermostatScheduleJson;
45 import org.openhab.core.library.types.DateTimeType;
46 import org.openhab.core.library.types.DecimalType;
47 import org.openhab.core.library.types.OnOffType;
48 import org.openhab.core.library.types.PointType;
49 import org.openhab.core.library.types.QuantityType;
50 import org.openhab.core.library.types.StringType;
51 import org.openhab.core.library.unit.ImperialUnits;
52 import org.openhab.core.thing.Channel;
53 import org.openhab.core.thing.ChannelUID;
54 import org.openhab.core.thing.Thing;
55 import org.openhab.core.thing.ThingStatus;
56 import org.openhab.core.thing.ThingStatusDetail;
57 import org.openhab.core.thing.binding.BaseThingHandler;
58 import org.openhab.core.thing.binding.ThingHandlerService;
59 import org.openhab.core.types.Command;
60 import org.openhab.core.types.RefreshType;
61 import org.openhab.core.types.State;
62 import org.openhab.core.types.StateOption;
63 import org.openhab.core.types.UnDefType;
64 import org.slf4j.Logger;
65 import org.slf4j.LoggerFactory;
67 import com.google.gson.Gson;
70 * The {@link RadioThermostatHandler} is responsible for handling commands, which are
71 * sent to one of the channels.
73 * Based on the 'airquality' binding by Kuba Wolanin
75 * @author Michael Lobstein - Initial contribution
78 public class RadioThermostatHandler extends BaseThingHandler implements RadioThermostatEventListener {
79 private static final int DEFAULT_REFRESH_PERIOD = 2;
80 private static final int DEFAULT_LOG_REFRESH_PERIOD = 10;
82 private final RadioThermostatStateDescriptionProvider stateDescriptionProvider;
83 private final Logger logger = LoggerFactory.getLogger(RadioThermostatHandler.class);
85 private final Gson gson;
86 private final RadioThermostatConnector connector;
87 private final RadioThermostatDTO rthermData = new RadioThermostatDTO();
89 private @Nullable ScheduledFuture<?> refreshJob;
90 private @Nullable ScheduledFuture<?> logRefreshJob;
91 private @Nullable ScheduledFuture<?> clockSyncJob;
93 private int refreshPeriod = DEFAULT_REFRESH_PERIOD;
94 private int logRefreshPeriod = DEFAULT_LOG_REFRESH_PERIOD;
95 private boolean isCT80 = false;
96 private boolean disableLogs = false;
97 private boolean clockSync = false;
98 private String setpointCmdKeyPrefix = "t_";
99 private String heatProgramJson = "";
100 private String coolProgramJson = "";
102 public RadioThermostatHandler(Thing thing, RadioThermostatStateDescriptionProvider stateDescriptionProvider,
103 HttpClient httpClient) {
105 this.stateDescriptionProvider = stateDescriptionProvider;
107 connector = new RadioThermostatConnector(httpClient);
111 public void initialize() {
112 logger.debug("Initializing RadioThermostat handler.");
113 RadioThermostatConfiguration config = getConfigAs(RadioThermostatConfiguration.class);
115 final String hostName = config.hostName;
116 final Integer refresh = config.refresh;
117 final Integer logRefresh = config.logRefresh;
118 this.isCT80 = config.isCT80;
119 this.disableLogs = config.disableLogs;
120 this.clockSync = config.clockSync;
122 if (hostName == null || "".equals(hostName)) {
123 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
124 "@text/offline.configuration-error-hostname");
128 if (refresh != null) {
129 this.refreshPeriod = refresh;
132 if (logRefresh != null) {
133 this.logRefreshPeriod = logRefresh;
136 connector.setThermostatHostName(hostName);
137 connector.addEventListener(this);
139 // The setpoint mode is controlled by the name of setpoint attribute sent to the thermostat.
140 // Temporary mode uses setpoint names prefixed with "t_" while absolute mode uses "a_"
141 if (config.setpointMode.equals("absolute")) {
142 this.setpointCmdKeyPrefix = "a_";
145 // populate fan mode options based on thermostat model
146 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), FAN_MODE), getFanModeOptions());
148 // if we are not a CT-80, remove the humidity & program mode channel
150 List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
151 channels.removeIf(c -> (c.getUID().getId().equals(HUMIDITY)));
152 channels.removeIf(c -> (c.getUID().getId().equals(PROGRAM_MODE)));
153 updateThing(editThing().withChannels(channels).build());
156 final RadioThermostatScheduleJson thermostatSchedule = new RadioThermostatScheduleJson(config);
159 heatProgramJson = thermostatSchedule.getHeatProgramJson();
160 } catch (IllegalStateException e) {
161 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
162 "@text/offline.configuration-error-heating-program");
167 coolProgramJson = thermostatSchedule.getCoolProgramJson();
168 } catch (IllegalStateException e) {
169 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
170 "@text/offline.configuration-error-cooling-program");
174 updateStatus(ThingStatus.UNKNOWN);
176 startAutomaticRefresh();
178 if (!this.disableLogs || this.isCT80) {
179 startAutomaticLogRefresh();
182 if (this.clockSync) {
183 scheduleClockSyncJob();
188 public Collection<Class<? extends ThingHandlerService>> getServices() {
189 return Collections.singletonList(RadioThermostatThingActions.class);
193 * Start the job to periodically update data from the thermostat
195 private void startAutomaticRefresh() {
196 ScheduledFuture<?> refreshJob = this.refreshJob;
197 if (refreshJob == null || refreshJob.isCancelled()) {
198 Runnable runnable = () -> {
199 // populate the heat and cool programs on the thermostat from the user configuration,
200 // the commands will be sent each time the refresh job runs until a success response is seen
201 if (!"".equals(heatProgramJson)) {
202 final String response = connector.sendCommand(null, null, heatProgramJson, HEAT_PROGRAM_RESOURCE);
203 if (response.contains("success")) {
204 heatProgramJson = "";
208 if (!"".equals(coolProgramJson)) {
209 final String response = connector.sendCommand(null, null, coolProgramJson, COOL_PROGRAM_RESOURCE);
210 if (response.contains("success")) {
211 coolProgramJson = "";
215 // send an async call to the thermostat to get the 'tstat' data
216 connector.getAsyncThermostatData(DEFAULT_RESOURCE);
220 this.refreshJob = scheduler.scheduleWithFixedDelay(runnable, 0, refreshPeriod, TimeUnit.MINUTES);
225 * Schedule the clock sync job
227 private void scheduleClockSyncJob() {
228 ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
229 if (clockSyncJob == null || clockSyncJob.isCancelled()) {
231 this.clockSyncJob = scheduler.schedule(this::syncThermostatClock, 1, TimeUnit.MINUTES);
236 * Sync the thermostat's clock with the host system clock
238 private void syncThermostatClock() {
239 Calendar c = Calendar.getInstance();
241 // The thermostat week starts as Monday = 0, subtract 2 since in standard DoW Monday = 2
242 int thermDayOfWeek = c.get(Calendar.DAY_OF_WEEK) - 2;
243 // Sunday will be -1, so add 7 to make it 6
244 if (thermDayOfWeek < 0) {
248 final String response = connector.sendCommand(null, null,
249 String.format(JSON_TIME, thermDayOfWeek, c.get(Calendar.HOUR_OF_DAY), c.get(Calendar.MINUTE)),
252 // if sync call was successful run again in one hour, if un-successful try again in one minute
253 this.clockSyncJob = scheduler.schedule(this::syncThermostatClock, (response.contains("success") ? 60 : 1),
258 * Start the job to periodically update humidity and runtime date from the thermostat
260 private void startAutomaticLogRefresh() {
261 ScheduledFuture<?> logRefreshJob = this.logRefreshJob;
262 if (logRefreshJob == null || logRefreshJob.isCancelled()) {
263 Runnable runnable = () -> {
264 // Request humidity data from the thermostat if we are a CT80
266 // send an async call to the thermostat to get the humidity data
267 connector.getAsyncThermostatData(HUMIDITY_RESOURCE);
270 if (!this.disableLogs) {
271 // send an async call to the thermostat to get the runtime data
272 connector.getAsyncThermostatData(RUNTIME_RESOURCE);
276 logRefreshJob = null;
277 this.logRefreshJob = scheduler.scheduleWithFixedDelay(runnable, (!this.clockSync ? 1 : 2), logRefreshPeriod,
283 public void dispose() {
284 logger.debug("Disposing the RadioThermostat handler.");
285 connector.removeEventListener(this);
287 ScheduledFuture<?> refreshJob = this.refreshJob;
288 if (refreshJob != null) {
289 refreshJob.cancel(true);
290 this.refreshJob = null;
293 ScheduledFuture<?> logRefreshJob = this.logRefreshJob;
294 if (logRefreshJob != null) {
295 logRefreshJob.cancel(true);
296 this.logRefreshJob = null;
299 ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
300 if (clockSyncJob != null) {
301 clockSyncJob.cancel(true);
302 this.clockSyncJob = null;
306 public void handleRawCommand(@Nullable String rawCommand) {
307 connector.sendCommand(null, null, rawCommand, DEFAULT_RESOURCE);
311 public void handleCommand(ChannelUID channelUID, Command command) {
312 if (command instanceof RefreshType) {
313 updateChannel(channelUID.getId(), rthermData);
316 String cmdStr = command.toString();
318 // parse out an Integer from the string
319 // ie '70.5 F' becomes 70, also handles negative numbers
320 cmdInt = NumberFormat.getInstance().parse(cmdStr).intValue();
321 } catch (ParseException e) {
322 logger.debug("Command: {} -> Not an integer", cmdStr);
325 switch (channelUID.getId()) {
327 // only do if commanded mode is different than current mode
328 if (!cmdInt.equals(rthermData.getThermostatData().getMode())) {
329 connector.sendCommand("tmode", cmdStr, DEFAULT_RESOURCE);
331 // set the new operating mode, reset everything else,
332 // because refreshing the tstat data below is really slow.
333 rthermData.getThermostatData().setMode(cmdInt);
334 rthermData.getThermostatData().setHeatTarget(Double.valueOf(0));
335 rthermData.getThermostatData().setCoolTarget(Double.valueOf(0));
336 updateChannel(SET_POINT, rthermData);
337 rthermData.getThermostatData().setHold(0);
338 updateChannel(HOLD, rthermData);
339 rthermData.getThermostatData().setProgramMode(-1);
340 updateChannel(PROGRAM_MODE, rthermData);
342 // now just trigger a refresh of the thermostat to get the new active setpoint
343 // this takes a while for the JSON request to complete (async).
344 connector.getAsyncThermostatData(DEFAULT_RESOURCE);
348 rthermData.getThermostatData().setFanMode(cmdInt);
349 connector.sendCommand("fmode", cmdStr, DEFAULT_RESOURCE);
352 rthermData.getThermostatData().setProgramMode(cmdInt);
353 connector.sendCommand("program_mode", cmdStr, DEFAULT_RESOURCE);
356 if (command instanceof OnOffType && command == OnOffType.ON) {
357 rthermData.getThermostatData().setHold(1);
358 connector.sendCommand("hold", "1", DEFAULT_RESOURCE);
359 } else if (command instanceof OnOffType && command == OnOffType.OFF) {
360 rthermData.getThermostatData().setHold(0);
361 connector.sendCommand("hold", "0", DEFAULT_RESOURCE);
365 String cmdKey = null;
366 if (rthermData.getThermostatData().getMode() == 1) {
367 cmdKey = this.setpointCmdKeyPrefix + "heat";
368 rthermData.getThermostatData().setHeatTarget(Double.valueOf(cmdInt));
369 } else if (rthermData.getThermostatData().getMode() == 2) {
370 cmdKey = this.setpointCmdKeyPrefix + "cool";
371 rthermData.getThermostatData().setCoolTarget(Double.valueOf(cmdInt));
373 // don't do anything if we are not in heat or cool mode
376 connector.sendCommand(cmdKey, cmdInt.toString(), DEFAULT_RESOURCE);
380 QuantityType<?> remoteTemp = ((QuantityType<Temperature>) command)
381 .toUnit(ImperialUnits.FAHRENHEIT);
382 connector.sendCommand("rem_temp", String.valueOf(remoteTemp.intValue()), REMOTE_TEMP_RESOURCE);
384 connector.sendCommand("rem_mode", "0", REMOTE_TEMP_RESOURCE);
388 logger.warn("Unsupported command: {}", command.toString());
394 * Handle a RadioThermostat event received from the listeners
396 * @param event the event received from the listeners
399 public void onNewMessageEvent(RadioThermostatEvent event) {
400 logger.debug("onNewMessageEvent: key {} = {}", event.getKey(), event.getValue());
402 String evtKey = event.getKey();
403 String evtVal = event.getValue();
405 if (KEY_ERROR.equals(evtKey)) {
406 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
407 "@text/offline.communication-error-get-data");
409 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
411 // Map the JSON response to the correct object and update appropriate channels
413 case DEFAULT_RESOURCE:
414 rthermData.setThermostatData(gson.fromJson(evtVal, RadioThermostatTstatDTO.class));
415 // if thermostat returned -1 for temperature, skip this update
416 if (rthermData.getThermostatData().getTemperature() >= 0) {
420 case HUMIDITY_RESOURCE:
421 RadioThermostatHumidityDTO dto = gson.fromJson(evtVal, RadioThermostatHumidityDTO.class);
422 // if thermostat returned -1 for humidity, skip this update
423 if (dto != null && dto.getHumidity() >= 0) {
424 rthermData.setHumidity(dto.getHumidity());
425 updateChannel(HUMIDITY, rthermData);
428 case RUNTIME_RESOURCE:
429 rthermData.setRuntime(gson.fromJson(evtVal, RadioThermostatRuntimeDTO.class));
430 updateChannel(TODAY_HEAT_RUNTIME, rthermData);
431 updateChannel(TODAY_COOL_RUNTIME, rthermData);
432 updateChannel(YESTERDAY_HEAT_RUNTIME, rthermData);
433 updateChannel(YESTERDAY_COOL_RUNTIME, rthermData);
440 * Update the channel from the last Thermostat data retrieved
442 * @param channelId the id identifying the channel to be updated
444 private void updateChannel(String channelId, RadioThermostatDTO rthermData) {
445 if (isLinked(channelId)) {
448 value = getValue(channelId, rthermData);
449 } catch (Exception e) {
450 logger.debug("Error setting {} value", channelId.toUpperCase());
456 state = UnDefType.UNDEF;
457 } else if (value instanceof PointType) {
458 state = (PointType) value;
459 } else if (value instanceof ZonedDateTime) {
460 state = new DateTimeType((ZonedDateTime) value);
461 } else if (value instanceof QuantityType<?>) {
462 state = (QuantityType<?>) value;
463 } else if (value instanceof BigDecimal) {
464 state = new DecimalType((BigDecimal) value);
465 } else if (value instanceof Integer) {
466 state = new DecimalType(BigDecimal.valueOf(((Integer) value).longValue()));
467 } else if (value instanceof String) {
468 state = new StringType(value.toString());
469 } else if (value instanceof OnOffType) {
470 state = (OnOffType) value;
472 logger.warn("Update channel {}: Unsupported value type {}", channelId,
473 value.getClass().getSimpleName());
475 logger.debug("Update channel {} with state {} ({})", channelId, (state == null) ? "null" : state.toString(),
476 (value == null) ? "null" : value.getClass().getSimpleName());
478 // Update the channel
480 updateState(channelId, state);
486 * Update a given channelId from the thermostat data
488 * @param the channel id to be updated
489 * @param data the RadioThermostat dto
490 * @return the value to be set in the state
492 public static @Nullable Object getValue(String channelId, RadioThermostatDTO data) {
495 if (data.getThermostatData().getTemperature() != null) {
496 return new QuantityType<Temperature>(data.getThermostatData().getTemperature(),
497 API_TEMPERATURE_UNIT);
502 if (data.getHumidity() != null) {
503 return new QuantityType<>(data.getHumidity(), API_HUMIDITY_UNIT);
508 return data.getThermostatData().getMode();
510 return data.getThermostatData().getFanMode();
512 return data.getThermostatData().getProgramMode();
514 if (data.getThermostatData().getSetpoint() != 0) {
515 return new QuantityType<Temperature>(data.getThermostatData().getSetpoint(), API_TEMPERATURE_UNIT);
520 return data.getThermostatData().getOverride();
522 return OnOffType.from(data.getThermostatData().getHold() == 1);
524 return data.getThermostatData().getStatus();
526 // workaround for some thermostats that don't report that the fan is on during heating or cooling
527 if (data.getThermostatData().getStatus() > 0) {
530 return data.getThermostatData().getFanStatus();
533 return data.getThermostatData().getTime().getDayOfWeek();
535 return data.getThermostatData().getTime().getHour();
537 return data.getThermostatData().getTime().getMinute();
539 return data.getThermostatData().getTime().getThemostatDateTime();
540 case TODAY_HEAT_RUNTIME:
541 return new QuantityType<>(data.getRuntime().getToday().getHeatTime().getRuntime(), API_MINUTES_UNIT);
542 case TODAY_COOL_RUNTIME:
543 return new QuantityType<>(data.getRuntime().getToday().getCoolTime().getRuntime(), API_MINUTES_UNIT);
544 case YESTERDAY_HEAT_RUNTIME:
545 return new QuantityType<>(data.getRuntime().getYesterday().getHeatTime().getRuntime(),
547 case YESTERDAY_COOL_RUNTIME:
548 return new QuantityType<>(data.getRuntime().getYesterday().getCoolTime().getRuntime(),
555 * Updates all channels from rthermData
557 private void updateAllChannels() {
558 // Update all channels from rthermData
559 for (Channel channel : getThing().getChannels()) {
560 updateChannel(channel.getUID().getId(), rthermData);
565 * Build a list of fan modes based on what model thermostat is used
567 * @return list of state options for thermostat fan modes
569 private List<StateOption> getFanModeOptions() {
570 List<StateOption> fanModeOptions = new ArrayList<>();
572 fanModeOptions.add(new StateOption("0", "Auto"));
574 fanModeOptions.add(new StateOption("1", "Auto/Circulate"));
576 fanModeOptions.add(new StateOption("2", "On"));
578 return fanModeOptions;