]> git.basschouten.com Git - openhab-addons.git/blob
26c6569d5c7cc17379d8d4729c77c98d931b4789
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.radiothermostat.internal.handler;
14
15 import static org.openhab.binding.radiothermostat.internal.RadioThermostatBindingConstants.*;
16
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;
26
27 import javax.measure.quantity.Temperature;
28
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;
64
65 import com.google.gson.Gson;
66
67 /**
68  * The {@link RadioThermostatHandler} is responsible for handling commands, which are
69  * sent to one of the channels.
70  *
71  * Based on the 'airquality' binding by Kuba Wolanin
72  *
73  * @author Michael Lobstein - Initial contribution
74  */
75 @NonNullByDefault
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;
79
80     private final RadioThermostatStateDescriptionProvider stateDescriptionProvider;
81     private final Logger logger = LoggerFactory.getLogger(RadioThermostatHandler.class);
82
83     private final Gson gson;
84     private final RadioThermostatConnector connector;
85     private final RadioThermostatDTO rthermData = new RadioThermostatDTO();
86
87     private @Nullable ScheduledFuture<?> refreshJob;
88     private @Nullable ScheduledFuture<?> logRefreshJob;
89     private @Nullable ScheduledFuture<?> clockSyncJob;
90
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;
99
100     public RadioThermostatHandler(Thing thing, RadioThermostatStateDescriptionProvider stateDescriptionProvider,
101             HttpClient httpClient) {
102         super(thing);
103         this.stateDescriptionProvider = stateDescriptionProvider;
104         gson = new Gson();
105         connector = new RadioThermostatConnector(httpClient);
106     }
107
108     @Override
109     public void initialize() {
110         logger.debug("Initializing RadioThermostat handler.");
111         RadioThermostatConfiguration config = getConfigAs(RadioThermostatConfiguration.class);
112
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;
119
120         if (hostName == null || hostName.isBlank()) {
121             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
122                     "@text/offline.configuration-error-hostname");
123             return;
124         }
125
126         if (refresh != null) {
127             this.refreshPeriod = refresh;
128         }
129
130         if (logRefresh != null) {
131             this.logRefreshPeriod = logRefresh;
132         }
133
134         connector.setThermostatHostName(hostName);
135         connector.addEventListener(this);
136
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 (config.setpointMode.equals("absolute")) {
140             this.setpointCmdKeyPrefix = "a_";
141         }
142
143         // populate fan mode options based on thermostat model
144         stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), FAN_MODE), getFanModeOptions());
145
146         // if we are not a CT-80, remove the humidity & program mode channel
147         if (!this.isCT80) {
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());
152         }
153
154         final RadioThermostatScheduleJson thermostatSchedule = new RadioThermostatScheduleJson(config);
155
156         try {
157             heatProgramJson = thermostatSchedule.getHeatProgramJson();
158         } catch (IllegalStateException e) {
159             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
160                     "@text/offline.configuration-error-heating-program");
161             return;
162         }
163
164         try {
165             coolProgramJson = thermostatSchedule.getCoolProgramJson();
166         } catch (IllegalStateException e) {
167             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
168                     "@text/offline.configuration-error-cooling-program");
169             return;
170         }
171
172         updateStatus(ThingStatus.UNKNOWN);
173
174         startAutomaticRefresh();
175
176         if (!this.disableLogs || this.isCT80) {
177             startAutomaticLogRefresh();
178         }
179
180         if (this.clockSync) {
181             scheduleClockSyncJob();
182         }
183     }
184
185     @Override
186     public Collection<Class<? extends ThingHandlerService>> getServices() {
187         return List.of(RadioThermostatThingActions.class);
188     }
189
190     /**
191      * Start the job to periodically update data from the thermostat
192      */
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;
203                     }
204                 }
205
206                 if (!coolProgramJson.isEmpty()) {
207                     final String response = connector.sendCommand(null, null, coolProgramJson, COOL_PROGRAM_RESOURCE);
208                     if (response.contains("success")) {
209                         coolProgramJson = BLANK;
210                     }
211                 }
212
213                 // send an async call to the thermostat to get the 'tstat' data
214                 connector.getAsyncThermostatData(DEFAULT_RESOURCE);
215             };
216
217             refreshJob = null;
218             this.refreshJob = scheduler.scheduleWithFixedDelay(runnable, 0, refreshPeriod, TimeUnit.MINUTES);
219         }
220     }
221
222     /**
223      * Schedule the clock sync job
224      */
225     private void scheduleClockSyncJob() {
226         ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
227         if (clockSyncJob == null || clockSyncJob.isCancelled()) {
228             clockSyncJob = null;
229             this.clockSyncJob = scheduler.schedule(this::syncThermostatClock, 1, TimeUnit.MINUTES);
230         }
231     }
232
233     /**
234      * Sync the thermostat's clock with the host system clock
235      */
236     private void syncThermostatClock() {
237         boolean success = false;
238
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();
243
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) {
248                 thermDayOfWeek += 7;
249             }
250
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");
254         }
255
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);
258     }
259
260     /**
261      * Start the job to periodically update humidity and runtime date from the thermostat
262      */
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
268                 if (this.isCT80) {
269                     // send an async call to the thermostat to get the humidity data
270                     connector.getAsyncThermostatData(HUMIDITY_RESOURCE);
271                 }
272
273                 if (!this.disableLogs) {
274                     // send an async call to the thermostat to get the runtime data
275                     connector.getAsyncThermostatData(RUNTIME_RESOURCE);
276                 }
277             };
278
279             logRefreshJob = null;
280             this.logRefreshJob = scheduler.scheduleWithFixedDelay(runnable, (!this.clockSync ? 1 : 2), logRefreshPeriod,
281                     TimeUnit.MINUTES);
282         }
283     }
284
285     @Override
286     public void dispose() {
287         logger.debug("Disposing the RadioThermostat handler.");
288         connector.removeEventListener(this);
289
290         // Disable Remote Temp and Message Area on shutdown
291         if (isLinked(REMOTE_TEMP)) {
292             connector.sendCommand("rem_mode", "0", REMOTE_TEMP_RESOURCE);
293         }
294
295         if (isLinked(MESSAGE)) {
296             connector.sendCommand("mode", "0", PMA_RESOURCE);
297         }
298
299         ScheduledFuture<?> refreshJob = this.refreshJob;
300         if (refreshJob != null) {
301             refreshJob.cancel(true);
302             this.refreshJob = null;
303         }
304
305         ScheduledFuture<?> logRefreshJob = this.logRefreshJob;
306         if (logRefreshJob != null) {
307             logRefreshJob.cancel(true);
308             this.logRefreshJob = null;
309         }
310
311         ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
312         if (clockSyncJob != null) {
313             clockSyncJob.cancel(true);
314             this.clockSyncJob = null;
315         }
316     }
317
318     public void handleRawCommand(@Nullable String rawCommand) {
319         connector.sendCommand(null, null, rawCommand, DEFAULT_RESOURCE);
320     }
321
322     public void handleRawCommand(@Nullable String rawCommand, String resource) {
323         connector.sendCommand(null, null, rawCommand, resource);
324     }
325
326     @Override
327     public void handleCommand(ChannelUID channelUID, Command command) {
328         if (command instanceof RefreshType) {
329             updateChannel(channelUID.getId(), rthermData);
330         } else {
331             Integer cmdInt = -1;
332             String cmdStr = command.toString();
333             try {
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);
339             }
340
341             switch (channelUID.getId()) {
342                 case MODE:
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);
346
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);
357
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);
361                     }
362                     break;
363                 case FAN_MODE:
364                     rthermData.getThermostatData().setFanMode(cmdInt);
365                     connector.sendCommand("fmode", cmdStr, DEFAULT_RESOURCE);
366                     break;
367                 case PROGRAM_MODE:
368                     rthermData.getThermostatData().setProgramMode(cmdInt);
369                     connector.sendCommand("program_mode", cmdStr, DEFAULT_RESOURCE);
370                     break;
371                 case HOLD:
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);
378                     }
379                     break;
380                 case SET_POINT:
381                     String cmdKey;
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);
390                     } else {
391                         // don't do anything if we are not in heat or cool mode
392                         break;
393                     }
394                     connector.sendCommand(cmdKey, cmdInt.toString(), DEFAULT_RESOURCE);
395                     break;
396                 case REMOTE_TEMP:
397                     if (cmdInt != -1) {
398                         QuantityType<?> remoteTemp = ((QuantityType<Temperature>) command)
399                                 .toUnit(ImperialUnits.FAHRENHEIT);
400                         connector.sendCommand("rem_temp", String.valueOf(remoteTemp.intValue()), REMOTE_TEMP_RESOURCE);
401                     } else {
402                         connector.sendCommand("rem_mode", "0", REMOTE_TEMP_RESOURCE);
403                     }
404                     break;
405                 case MESSAGE:
406                     if (!cmdStr.isEmpty()) {
407                         connector.sendCommand(null, null, String.format(JSON_PMA, cmdStr), PMA_RESOURCE);
408                     } else {
409                         connector.sendCommand("mode", "0", PMA_RESOURCE);
410                     }
411                     break;
412                 default:
413                     logger.warn("Unsupported command: {}", command.toString());
414             }
415         }
416     }
417
418     /**
419      * Handle a RadioThermostat event received from the listeners
420      *
421      * @param event the event received from the listeners
422      */
423     @Override
424     public void onNewMessageEvent(RadioThermostatEvent event) {
425         logger.debug("onNewMessageEvent: key {} = {}", event.getKey(), event.getValue());
426
427         String evtKey = event.getKey();
428         String evtVal = event.getValue();
429
430         if (KEY_ERROR.equals(evtKey)) {
431             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
432                     "@text/offline.communication-error-get-data");
433         } else {
434             updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
435
436             // Map the JSON response to the correct object and update appropriate channels
437             switch (evtKey) {
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) {
442                         updateAllChannels();
443                     }
444                     break;
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);
451                     }
452                     break;
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);
459                     break;
460             }
461         }
462     }
463
464     /**
465      * Update the channel from the last Thermostat data retrieved
466      *
467      * @param channelId the id identifying the channel to be updated
468      */
469     private void updateChannel(String channelId, RadioThermostatDTO rthermData) {
470         if (isLinked(channelId)) {
471             Object value;
472             try {
473                 value = getValue(channelId, rthermData);
474             } catch (Exception e) {
475                 logger.debug("Error setting {} value", channelId.toUpperCase());
476                 return;
477             }
478
479             State state = null;
480             if (value == null) {
481                 state = UnDefType.UNDEF;
482             } else if (value instanceof PointType) {
483                 state = (PointType) value;
484             } else if (value instanceof ZonedDateTime) {
485                 state = new DateTimeType((ZonedDateTime) value);
486             } else if (value instanceof QuantityType<?>) {
487                 state = (QuantityType<?>) value;
488             } else if (value instanceof Number) {
489                 state = new DecimalType((Number) value);
490             } else if (value instanceof String) {
491                 state = new StringType(value.toString());
492             } else if (value instanceof OnOffType) {
493                 state = (OnOffType) value;
494             } else {
495                 logger.warn("Update channel {}: Unsupported value type {}", channelId,
496                         value.getClass().getSimpleName());
497             }
498             logger.debug("Update channel {} with state {} ({})", channelId, (state == null) ? "null" : state.toString(),
499                     (value == null) ? "null" : value.getClass().getSimpleName());
500
501             // Update the channel
502             if (state != null) {
503                 updateState(channelId, state);
504             }
505         }
506     }
507
508     /**
509      * Update a given channelId from the thermostat data
510      *
511      * @param the channel id to be updated
512      * @param data the RadioThermostat dto
513      * @return the value to be set in the state
514      */
515     public static @Nullable Object getValue(String channelId, RadioThermostatDTO data) {
516         switch (channelId) {
517             case TEMPERATURE:
518                 if (data.getThermostatData().getTemperature() != null) {
519                     return new QuantityType<Temperature>(data.getThermostatData().getTemperature(),
520                             API_TEMPERATURE_UNIT);
521                 } else {
522                     return null;
523                 }
524             case HUMIDITY:
525                 if (data.getHumidity() != null) {
526                     return new QuantityType<>(data.getHumidity(), API_HUMIDITY_UNIT);
527                 } else {
528                     return null;
529                 }
530             case MODE:
531                 return data.getThermostatData().getMode();
532             case FAN_MODE:
533                 return data.getThermostatData().getFanMode();
534             case PROGRAM_MODE:
535                 return data.getThermostatData().getProgramMode();
536             case SET_POINT:
537                 if (data.getThermostatData().getSetpoint() != 0) {
538                     return new QuantityType<Temperature>(data.getThermostatData().getSetpoint(), API_TEMPERATURE_UNIT);
539                 } else {
540                     return null;
541                 }
542             case OVERRIDE:
543                 return data.getThermostatData().getOverride();
544             case HOLD:
545                 return OnOffType.from(data.getThermostatData().getHold() == 1);
546             case STATUS:
547                 return data.getThermostatData().getStatus();
548             case FAN_STATUS:
549                 // workaround for some thermostats that don't report that the fan is on during heating or cooling
550                 if (data.getThermostatData().getStatus() > 0) {
551                     return 1;
552                 } else {
553                     return data.getThermostatData().getFanStatus();
554                 }
555             case DAY:
556                 return data.getThermostatData().getTime().getDayOfWeek();
557             case HOUR:
558                 return data.getThermostatData().getTime().getHour();
559             case MINUTE:
560                 return data.getThermostatData().getTime().getMinute();
561             case DATE_STAMP:
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(),
569                         API_MINUTES_UNIT);
570             case YESTERDAY_COOL_RUNTIME:
571                 return new QuantityType<>(data.getRuntime().getYesterday().getCoolTime().getRuntime(),
572                         API_MINUTES_UNIT);
573         }
574         return null;
575     }
576
577     /**
578      * Updates all channels from rthermData
579      */
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);
585             }
586         });
587     }
588
589     /**
590      * Build a list of fan modes based on what model thermostat is used
591      *
592      * @return list of state options for thermostat fan modes
593      */
594     private List<StateOption> getFanModeOptions() {
595         List<StateOption> fanModeOptions = new ArrayList<>();
596
597         fanModeOptions.add(new StateOption("0", "@text/options.fan-option-auto"));
598         if (this.isCT80) {
599             fanModeOptions.add(new StateOption("1", "@text/options.fan-option-circulate"));
600         }
601         fanModeOptions.add(new StateOption("2", "@text/options.fan-option-on"));
602
603         return fanModeOptions;
604     }
605 }