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.text.NumberFormat;
18 import java.text.ParseException;
19 import java.time.ZonedDateTime;
20 import java.util.ArrayList;
21 import java.util.Calendar;
22 import java.util.Collection;
23 import java.util.List;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
27 import javax.measure.quantity.Temperature;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.eclipse.jetty.client.HttpClient;
32 import org.openhab.binding.radiothermostat.internal.RadioThermostatConfiguration;
33 import org.openhab.binding.radiothermostat.internal.RadioThermostatStateDescriptionProvider;
34 import org.openhab.binding.radiothermostat.internal.RadioThermostatThingActions;
35 import org.openhab.binding.radiothermostat.internal.communication.RadioThermostatConnector;
36 import org.openhab.binding.radiothermostat.internal.communication.RadioThermostatEvent;
37 import org.openhab.binding.radiothermostat.internal.communication.RadioThermostatEventListener;
38 import org.openhab.binding.radiothermostat.internal.dto.RadioThermostatDTO;
39 import org.openhab.binding.radiothermostat.internal.dto.RadioThermostatHumidityDTO;
40 import org.openhab.binding.radiothermostat.internal.dto.RadioThermostatRuntimeDTO;
41 import org.openhab.binding.radiothermostat.internal.dto.RadioThermostatTstatDTO;
42 import org.openhab.binding.radiothermostat.internal.util.RadioThermostatScheduleJson;
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.library.unit.ImperialUnits;
50 import org.openhab.core.thing.Channel;
51 import org.openhab.core.thing.ChannelUID;
52 import org.openhab.core.thing.Thing;
53 import org.openhab.core.thing.ThingStatus;
54 import org.openhab.core.thing.ThingStatusDetail;
55 import org.openhab.core.thing.binding.BaseThingHandler;
56 import org.openhab.core.thing.binding.ThingHandlerService;
57 import org.openhab.core.types.Command;
58 import org.openhab.core.types.RefreshType;
59 import org.openhab.core.types.State;
60 import org.openhab.core.types.StateOption;
61 import org.openhab.core.types.UnDefType;
62 import org.slf4j.Logger;
63 import org.slf4j.LoggerFactory;
65 import com.google.gson.Gson;
68 * The {@link RadioThermostatHandler} is responsible for handling commands, which are
69 * sent to one of the channels.
71 * Based on the 'airquality' binding by Kuba Wolanin
73 * @author Michael Lobstein - Initial contribution
76 public class RadioThermostatHandler extends BaseThingHandler implements RadioThermostatEventListener {
77 private static final int DEFAULT_REFRESH_PERIOD = 2;
78 private static final int DEFAULT_LOG_REFRESH_PERIOD = 10;
80 private final RadioThermostatStateDescriptionProvider stateDescriptionProvider;
81 private final Logger logger = LoggerFactory.getLogger(RadioThermostatHandler.class);
83 private final Gson gson;
84 private final RadioThermostatConnector connector;
85 private final RadioThermostatDTO rthermData = new RadioThermostatDTO();
87 private @Nullable ScheduledFuture<?> refreshJob;
88 private @Nullable ScheduledFuture<?> logRefreshJob;
89 private @Nullable ScheduledFuture<?> clockSyncJob;
91 private int refreshPeriod = DEFAULT_REFRESH_PERIOD;
92 private int logRefreshPeriod = DEFAULT_LOG_REFRESH_PERIOD;
93 private boolean isCT80 = false;
94 private boolean disableLogs = false;
95 private boolean clockSync = false;
96 private String setpointCmdKeyPrefix = "t_";
97 private String heatProgramJson = BLANK;
98 private String coolProgramJson = BLANK;
100 public RadioThermostatHandler(Thing thing, RadioThermostatStateDescriptionProvider stateDescriptionProvider,
101 HttpClient httpClient) {
103 this.stateDescriptionProvider = stateDescriptionProvider;
105 connector = new RadioThermostatConnector(httpClient);
109 public void initialize() {
110 logger.debug("Initializing RadioThermostat handler.");
111 RadioThermostatConfiguration config = getConfigAs(RadioThermostatConfiguration.class);
113 final String hostName = config.hostName;
114 final Integer refresh = config.refresh;
115 final Integer logRefresh = config.logRefresh;
116 this.isCT80 = config.isCT80;
117 this.disableLogs = config.disableLogs;
118 this.clockSync = config.clockSync;
120 if (hostName == null || hostName.isBlank()) {
121 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
122 "@text/offline.configuration-error-hostname");
126 if (refresh != null) {
127 this.refreshPeriod = refresh;
130 if (logRefresh != null) {
131 this.logRefreshPeriod = logRefresh;
134 connector.setThermostatHostName(hostName);
135 connector.addEventListener(this);
137 // The setpoint mode is controlled by the name of setpoint attribute sent to the thermostat.
138 // Temporary mode uses setpoint names prefixed with "t_" while absolute mode uses "a_"
139 if ("absolute".equals(config.setpointMode)) {
140 this.setpointCmdKeyPrefix = "a_";
143 // populate fan mode options based on thermostat model
144 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), FAN_MODE), getFanModeOptions());
146 // if we are not a CT-80, remove the humidity & program mode channel
148 List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
149 channels.removeIf(c -> (c.getUID().getId().equals(HUMIDITY)));
150 channels.removeIf(c -> (c.getUID().getId().equals(PROGRAM_MODE)));
151 updateThing(editThing().withChannels(channels).build());
154 final RadioThermostatScheduleJson thermostatSchedule = new RadioThermostatScheduleJson(config);
157 heatProgramJson = thermostatSchedule.getHeatProgramJson();
158 } catch (IllegalStateException e) {
159 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
160 "@text/offline.configuration-error-heating-program");
165 coolProgramJson = thermostatSchedule.getCoolProgramJson();
166 } catch (IllegalStateException e) {
167 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
168 "@text/offline.configuration-error-cooling-program");
172 updateStatus(ThingStatus.UNKNOWN);
174 startAutomaticRefresh();
176 if (!this.disableLogs || this.isCT80) {
177 startAutomaticLogRefresh();
180 if (this.clockSync) {
181 scheduleClockSyncJob();
186 public Collection<Class<? extends ThingHandlerService>> getServices() {
187 return List.of(RadioThermostatThingActions.class);
191 * Start the job to periodically update data from the thermostat
193 private void startAutomaticRefresh() {
194 ScheduledFuture<?> refreshJob = this.refreshJob;
195 if (refreshJob == null || refreshJob.isCancelled()) {
196 Runnable runnable = () -> {
197 // populate the heat and cool programs on the thermostat from the user configuration,
198 // the commands will be sent each time the refresh job runs until a success response is seen
199 if (!heatProgramJson.isEmpty()) {
200 final String response = connector.sendCommand(null, null, heatProgramJson, HEAT_PROGRAM_RESOURCE);
201 if (response.contains("success")) {
202 heatProgramJson = BLANK;
206 if (!coolProgramJson.isEmpty()) {
207 final String response = connector.sendCommand(null, null, coolProgramJson, COOL_PROGRAM_RESOURCE);
208 if (response.contains("success")) {
209 coolProgramJson = BLANK;
213 // send an async call to the thermostat to get the 'tstat' data
214 connector.getAsyncThermostatData(DEFAULT_RESOURCE);
218 this.refreshJob = scheduler.scheduleWithFixedDelay(runnable, 0, refreshPeriod, TimeUnit.MINUTES);
223 * Schedule the clock sync job
225 private void scheduleClockSyncJob() {
226 ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
227 if (clockSyncJob == null || clockSyncJob.isCancelled()) {
229 this.clockSyncJob = scheduler.schedule(this::syncThermostatClock, 1, TimeUnit.MINUTES);
234 * Sync the thermostat's clock with the host system clock
236 private void syncThermostatClock() {
237 boolean success = false;
239 // don't sync clock if override is on because it will reset temporary hold
240 final Integer override = rthermData.getThermostatData().getOverride();
241 if (override == null || override.compareTo(0) == 0) {
242 Calendar c = Calendar.getInstance();
244 // The thermostat week starts as Monday = 0, subtract 2 since in standard DoW Monday = 2
245 int thermDayOfWeek = c.get(Calendar.DAY_OF_WEEK) - 2;
246 // Sunday will be -1, so add 7 to make it 6
247 if (thermDayOfWeek < 0) {
251 success = connector.sendCommand(null, null,
252 String.format(JSON_TIME, thermDayOfWeek, c.get(Calendar.HOUR_OF_DAY), c.get(Calendar.MINUTE)),
253 TIME_RESOURCE).contains("success");
256 // if sync call was successful run again in one hour, if un-successful try again in one minute
257 this.clockSyncJob = scheduler.schedule(this::syncThermostatClock, (success ? 60 : 1), TimeUnit.MINUTES);
261 * Start the job to periodically update humidity and runtime date from the thermostat
263 private void startAutomaticLogRefresh() {
264 ScheduledFuture<?> logRefreshJob = this.logRefreshJob;
265 if (logRefreshJob == null || logRefreshJob.isCancelled()) {
266 Runnable runnable = () -> {
267 // Request humidity data from the thermostat if we are a CT80
269 // send an async call to the thermostat to get the humidity data
270 connector.getAsyncThermostatData(HUMIDITY_RESOURCE);
273 if (!this.disableLogs) {
274 // send an async call to the thermostat to get the runtime data
275 connector.getAsyncThermostatData(RUNTIME_RESOURCE);
279 logRefreshJob = null;
280 this.logRefreshJob = scheduler.scheduleWithFixedDelay(runnable, (!this.clockSync ? 1 : 2), logRefreshPeriod,
286 public void dispose() {
287 logger.debug("Disposing the RadioThermostat handler.");
288 connector.removeEventListener(this);
290 // Disable Remote Temp and Message Area on shutdown
291 if (isLinked(REMOTE_TEMP)) {
292 connector.sendCommand("rem_mode", "0", REMOTE_TEMP_RESOURCE);
295 if (isLinked(MESSAGE)) {
296 connector.sendCommand("mode", "0", PMA_RESOURCE);
299 ScheduledFuture<?> refreshJob = this.refreshJob;
300 if (refreshJob != null) {
301 refreshJob.cancel(true);
302 this.refreshJob = null;
305 ScheduledFuture<?> logRefreshJob = this.logRefreshJob;
306 if (logRefreshJob != null) {
307 logRefreshJob.cancel(true);
308 this.logRefreshJob = null;
311 ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
312 if (clockSyncJob != null) {
313 clockSyncJob.cancel(true);
314 this.clockSyncJob = null;
318 public void handleRawCommand(@Nullable String rawCommand) {
319 connector.sendCommand(null, null, rawCommand, DEFAULT_RESOURCE);
322 public void handleRawCommand(@Nullable String rawCommand, String resource) {
323 connector.sendCommand(null, null, rawCommand, resource);
327 public void handleCommand(ChannelUID channelUID, Command command) {
328 if (command instanceof RefreshType) {
329 updateChannel(channelUID.getId(), rthermData);
332 String cmdStr = command.toString();
334 // parse out an Integer from the string
335 // ie '70.5 F' becomes 70, also handles negative numbers
336 cmdInt = NumberFormat.getInstance().parse(cmdStr).intValue();
337 } catch (ParseException e) {
338 logger.debug("Command: {} -> Not an integer", cmdStr);
341 switch (channelUID.getId()) {
343 // only do if commanded mode is different than current mode
344 if (!cmdInt.equals(rthermData.getThermostatData().getMode())) {
345 connector.sendCommand("tmode", cmdStr, DEFAULT_RESOURCE);
347 // set the new operating mode, reset everything else,
348 // because refreshing the tstat data below is really slow.
349 rthermData.getThermostatData().setMode(cmdInt);
350 rthermData.getThermostatData().setHeatTarget(Double.valueOf(0));
351 rthermData.getThermostatData().setCoolTarget(Double.valueOf(0));
352 updateChannel(SET_POINT, rthermData);
353 rthermData.getThermostatData().setHold(0);
354 updateChannel(HOLD, rthermData);
355 rthermData.getThermostatData().setProgramMode(-1);
356 updateChannel(PROGRAM_MODE, rthermData);
358 // now just trigger a refresh of the thermostat to get the new active setpoint
359 // this takes a while for the JSON request to complete (async).
360 connector.getAsyncThermostatData(DEFAULT_RESOURCE);
364 rthermData.getThermostatData().setFanMode(cmdInt);
365 connector.sendCommand("fmode", cmdStr, DEFAULT_RESOURCE);
368 rthermData.getThermostatData().setProgramMode(cmdInt);
369 connector.sendCommand("program_mode", cmdStr, DEFAULT_RESOURCE);
372 if (command instanceof OnOffType && command == OnOffType.ON) {
373 rthermData.getThermostatData().setHold(1);
374 connector.sendCommand("hold", "1", DEFAULT_RESOURCE);
375 } else if (command instanceof OnOffType && command == OnOffType.OFF) {
376 rthermData.getThermostatData().setHold(0);
377 connector.sendCommand("hold", "0", DEFAULT_RESOURCE);
382 if (rthermData.getThermostatData().getMode() == 1) {
383 cmdKey = this.setpointCmdKeyPrefix + "heat";
384 rthermData.getThermostatData().setHeatTarget(Double.valueOf(cmdInt));
385 rthermData.getThermostatData().setOverride(1);
386 } else if (rthermData.getThermostatData().getMode() == 2) {
387 cmdKey = this.setpointCmdKeyPrefix + "cool";
388 rthermData.getThermostatData().setCoolTarget(Double.valueOf(cmdInt));
389 rthermData.getThermostatData().setOverride(1);
391 // don't do anything if we are not in heat or cool mode
394 connector.sendCommand(cmdKey, cmdInt.toString(), DEFAULT_RESOURCE);
398 QuantityType<?> remoteTemp = ((QuantityType<Temperature>) command)
399 .toUnit(ImperialUnits.FAHRENHEIT);
400 connector.sendCommand("rem_temp", String.valueOf(remoteTemp.intValue()), REMOTE_TEMP_RESOURCE);
402 connector.sendCommand("rem_mode", "0", REMOTE_TEMP_RESOURCE);
406 if (!cmdStr.isEmpty()) {
407 connector.sendCommand(null, null, String.format(JSON_PMA, cmdStr), PMA_RESOURCE);
409 connector.sendCommand("mode", "0", PMA_RESOURCE);
413 logger.warn("Unsupported command: {}", command.toString());
419 * Handle a RadioThermostat event received from the listeners
421 * @param event the event received from the listeners
424 public void onNewMessageEvent(RadioThermostatEvent event) {
425 logger.debug("onNewMessageEvent: key {} = {}", event.getKey(), event.getValue());
427 String evtKey = event.getKey();
428 String evtVal = event.getValue();
430 if (KEY_ERROR.equals(evtKey)) {
431 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
432 "@text/offline.communication-error-get-data");
434 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
436 // Map the JSON response to the correct object and update appropriate channels
438 case DEFAULT_RESOURCE:
439 rthermData.setThermostatData(gson.fromJson(evtVal, RadioThermostatTstatDTO.class));
440 // if thermostat returned -1 for temperature, skip this update
441 if (rthermData.getThermostatData().getTemperature() >= 0) {
445 case HUMIDITY_RESOURCE:
446 RadioThermostatHumidityDTO dto = gson.fromJson(evtVal, RadioThermostatHumidityDTO.class);
447 // if thermostat returned -1 for humidity, skip this update
448 if (dto != null && dto.getHumidity() >= 0) {
449 rthermData.setHumidity(dto.getHumidity());
450 updateChannel(HUMIDITY, rthermData);
453 case RUNTIME_RESOURCE:
454 rthermData.setRuntime(gson.fromJson(evtVal, RadioThermostatRuntimeDTO.class));
455 updateChannel(TODAY_HEAT_RUNTIME, rthermData);
456 updateChannel(TODAY_COOL_RUNTIME, rthermData);
457 updateChannel(YESTERDAY_HEAT_RUNTIME, rthermData);
458 updateChannel(YESTERDAY_COOL_RUNTIME, rthermData);
465 * Update the channel from the last Thermostat data retrieved
467 * @param channelId the id identifying the channel to be updated
469 private void updateChannel(String channelId, RadioThermostatDTO rthermData) {
470 if (isLinked(channelId)) {
473 value = getValue(channelId, rthermData);
474 } catch (Exception e) {
475 logger.debug("Error setting {} value", channelId.toUpperCase());
481 state = UnDefType.UNDEF;
482 } else if (value instanceof PointType pointCommand) {
483 state = pointCommand;
484 } else if (value instanceof ZonedDateTime zonedDateTimeCommand) {
485 state = new DateTimeType(zonedDateTimeCommand);
486 } else if (value instanceof QuantityType<?> quantityCommand) {
487 state = quantityCommand;
488 } else if (value instanceof Number numberCommand) {
489 state = new DecimalType(numberCommand);
490 } else if (value instanceof String) {
491 state = new StringType(value.toString());
492 } else if (value instanceof OnOffType onOffCommand) {
493 state = onOffCommand;
495 logger.warn("Update channel {}: Unsupported value type {}", channelId,
496 value.getClass().getSimpleName());
498 logger.debug("Update channel {} with state {} ({})", channelId, (state == null) ? "null" : state.toString(),
499 (value == null) ? "null" : value.getClass().getSimpleName());
501 // Update the channel
503 updateState(channelId, state);
509 * Update a given channelId from the thermostat data
511 * @param channelId the channel id to be updated
512 * @param data the RadioThermostat dto
513 * @return the value to be set in the state
515 public static @Nullable Object getValue(String channelId, RadioThermostatDTO data) {
518 if (data.getThermostatData().getTemperature() != null) {
519 return new QuantityType<Temperature>(data.getThermostatData().getTemperature(),
520 API_TEMPERATURE_UNIT);
525 if (data.getHumidity() != null) {
526 return new QuantityType<>(data.getHumidity(), API_HUMIDITY_UNIT);
531 return data.getThermostatData().getMode();
533 return data.getThermostatData().getFanMode();
535 return data.getThermostatData().getProgramMode();
537 if (data.getThermostatData().getSetpoint() != 0) {
538 return new QuantityType<Temperature>(data.getThermostatData().getSetpoint(), API_TEMPERATURE_UNIT);
543 return data.getThermostatData().getOverride();
545 return OnOffType.from(data.getThermostatData().getHold() == 1);
547 return data.getThermostatData().getStatus();
549 // workaround for some thermostats that don't report that the fan is on during heating or cooling
550 if (data.getThermostatData().getStatus() > 0) {
553 return data.getThermostatData().getFanStatus();
556 return data.getThermostatData().getTime().getDayOfWeek();
558 return data.getThermostatData().getTime().getHour();
560 return data.getThermostatData().getTime().getMinute();
562 return data.getThermostatData().getTime().getThemostatDateTime();
563 case TODAY_HEAT_RUNTIME:
564 return new QuantityType<>(data.getRuntime().getToday().getHeatTime().getRuntime(), API_MINUTES_UNIT);
565 case TODAY_COOL_RUNTIME:
566 return new QuantityType<>(data.getRuntime().getToday().getCoolTime().getRuntime(), API_MINUTES_UNIT);
567 case YESTERDAY_HEAT_RUNTIME:
568 return new QuantityType<>(data.getRuntime().getYesterday().getHeatTime().getRuntime(),
570 case YESTERDAY_COOL_RUNTIME:
571 return new QuantityType<>(data.getRuntime().getYesterday().getCoolTime().getRuntime(),
578 * Updates all channels from rthermData
580 private void updateAllChannels() {
581 // Update all channels from rthermData
582 getThing().getChannels().forEach(channel -> {
583 if (!NO_UPDATE_CHANNEL_IDS.contains(channel.getUID().getId())) {
584 updateChannel(channel.getUID().getId(), rthermData);
590 * Build a list of fan modes based on what model thermostat is used
592 * @return list of state options for thermostat fan modes
594 private List<StateOption> getFanModeOptions() {
595 List<StateOption> fanModeOptions = new ArrayList<>();
597 fanModeOptions.add(new StateOption("0", "@text/options.fan-option-auto"));
599 fanModeOptions.add(new StateOption("1", "@text/options.fan-option-circulate"));
601 fanModeOptions.add(new StateOption("2", "@text/options.fan-option-on"));
603 return fanModeOptions;