]> git.basschouten.com Git - openhab-addons.git/blob
b1dd1e32b693a16069c96f07a3098c343d199bb5
[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.Collections;
24 import java.util.List;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
27
28 import javax.measure.quantity.Temperature;
29
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.binding.radiothermostat.internal.util.RadioThermostatScheduleJson;
44 import org.openhab.core.library.types.DateTimeType;
45 import org.openhab.core.library.types.DecimalType;
46 import org.openhab.core.library.types.OnOffType;
47 import org.openhab.core.library.types.PointType;
48 import org.openhab.core.library.types.QuantityType;
49 import org.openhab.core.library.types.StringType;
50 import org.openhab.core.library.unit.ImperialUnits;
51 import org.openhab.core.thing.Channel;
52 import org.openhab.core.thing.ChannelUID;
53 import org.openhab.core.thing.Thing;
54 import org.openhab.core.thing.ThingStatus;
55 import org.openhab.core.thing.ThingStatusDetail;
56 import org.openhab.core.thing.binding.BaseThingHandler;
57 import org.openhab.core.thing.binding.ThingHandlerService;
58 import org.openhab.core.types.Command;
59 import org.openhab.core.types.RefreshType;
60 import org.openhab.core.types.State;
61 import org.openhab.core.types.StateOption;
62 import org.openhab.core.types.UnDefType;
63 import org.slf4j.Logger;
64 import org.slf4j.LoggerFactory;
65
66 import com.google.gson.Gson;
67
68 /**
69  * The {@link RadioThermostatHandler} is responsible for handling commands, which are
70  * sent to one of the channels.
71  *
72  * Based on the 'airquality' binding by Kuba Wolanin
73  *
74  * @author Michael Lobstein - Initial contribution
75  */
76 @NonNullByDefault
77 public class RadioThermostatHandler extends BaseThingHandler implements RadioThermostatEventListener {
78     private static final int DEFAULT_REFRESH_PERIOD = 2;
79     private static final int DEFAULT_LOG_REFRESH_PERIOD = 10;
80
81     private final RadioThermostatStateDescriptionProvider stateDescriptionProvider;
82     private final Logger logger = LoggerFactory.getLogger(RadioThermostatHandler.class);
83
84     private final Gson gson;
85     private final RadioThermostatConnector connector;
86     private final RadioThermostatDTO rthermData = new RadioThermostatDTO();
87
88     private @Nullable ScheduledFuture<?> refreshJob;
89     private @Nullable ScheduledFuture<?> logRefreshJob;
90     private @Nullable ScheduledFuture<?> clockSyncJob;
91
92     private int refreshPeriod = DEFAULT_REFRESH_PERIOD;
93     private int logRefreshPeriod = DEFAULT_LOG_REFRESH_PERIOD;
94     private boolean isCT80 = false;
95     private boolean disableLogs = false;
96     private boolean clockSync = false;
97     private String setpointCmdKeyPrefix = "t_";
98     private String heatProgramJson = BLANK;
99     private String coolProgramJson = BLANK;
100
101     public RadioThermostatHandler(Thing thing, RadioThermostatStateDescriptionProvider stateDescriptionProvider,
102             HttpClient httpClient) {
103         super(thing);
104         this.stateDescriptionProvider = stateDescriptionProvider;
105         gson = new Gson();
106         connector = new RadioThermostatConnector(httpClient);
107     }
108
109     @Override
110     public void initialize() {
111         logger.debug("Initializing RadioThermostat handler.");
112         RadioThermostatConfiguration config = getConfigAs(RadioThermostatConfiguration.class);
113
114         final String hostName = config.hostName;
115         final Integer refresh = config.refresh;
116         final Integer logRefresh = config.logRefresh;
117         this.isCT80 = config.isCT80;
118         this.disableLogs = config.disableLogs;
119         this.clockSync = config.clockSync;
120
121         if (hostName == null || hostName.isBlank()) {
122             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
123                     "@text/offline.configuration-error-hostname");
124             return;
125         }
126
127         if (refresh != null) {
128             this.refreshPeriod = refresh;
129         }
130
131         if (logRefresh != null) {
132             this.logRefreshPeriod = logRefresh;
133         }
134
135         connector.setThermostatHostName(hostName);
136         connector.addEventListener(this);
137
138         // The setpoint mode is controlled by the name of setpoint attribute sent to the thermostat.
139         // Temporary mode uses setpoint names prefixed with "t_" while absolute mode uses "a_"
140         if (config.setpointMode.equals("absolute")) {
141             this.setpointCmdKeyPrefix = "a_";
142         }
143
144         // populate fan mode options based on thermostat model
145         stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), FAN_MODE), getFanModeOptions());
146
147         // if we are not a CT-80, remove the humidity & program mode channel
148         if (!this.isCT80) {
149             List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
150             channels.removeIf(c -> (c.getUID().getId().equals(HUMIDITY)));
151             channels.removeIf(c -> (c.getUID().getId().equals(PROGRAM_MODE)));
152             updateThing(editThing().withChannels(channels).build());
153         }
154
155         final RadioThermostatScheduleJson thermostatSchedule = new RadioThermostatScheduleJson(config);
156
157         try {
158             heatProgramJson = thermostatSchedule.getHeatProgramJson();
159         } catch (IllegalStateException e) {
160             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
161                     "@text/offline.configuration-error-heating-program");
162             return;
163         }
164
165         try {
166             coolProgramJson = thermostatSchedule.getCoolProgramJson();
167         } catch (IllegalStateException e) {
168             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
169                     "@text/offline.configuration-error-cooling-program");
170             return;
171         }
172
173         updateStatus(ThingStatus.UNKNOWN);
174
175         startAutomaticRefresh();
176
177         if (!this.disableLogs || this.isCT80) {
178             startAutomaticLogRefresh();
179         }
180
181         if (this.clockSync) {
182             scheduleClockSyncJob();
183         }
184     }
185
186     @Override
187     public Collection<Class<? extends ThingHandlerService>> getServices() {
188         return Collections.singletonList(RadioThermostatThingActions.class);
189     }
190
191     /**
192      * Start the job to periodically update data from the thermostat
193      */
194     private void startAutomaticRefresh() {
195         ScheduledFuture<?> refreshJob = this.refreshJob;
196         if (refreshJob == null || refreshJob.isCancelled()) {
197             Runnable runnable = () -> {
198                 // populate the heat and cool programs on the thermostat from the user configuration,
199                 // the commands will be sent each time the refresh job runs until a success response is seen
200                 if (!heatProgramJson.isEmpty()) {
201                     final String response = connector.sendCommand(null, null, heatProgramJson, HEAT_PROGRAM_RESOURCE);
202                     if (response.contains("success")) {
203                         heatProgramJson = BLANK;
204                     }
205                 }
206
207                 if (!coolProgramJson.isEmpty()) {
208                     final String response = connector.sendCommand(null, null, coolProgramJson, COOL_PROGRAM_RESOURCE);
209                     if (response.contains("success")) {
210                         coolProgramJson = BLANK;
211                     }
212                 }
213
214                 // send an async call to the thermostat to get the 'tstat' data
215                 connector.getAsyncThermostatData(DEFAULT_RESOURCE);
216             };
217
218             refreshJob = null;
219             this.refreshJob = scheduler.scheduleWithFixedDelay(runnable, 0, refreshPeriod, TimeUnit.MINUTES);
220         }
221     }
222
223     /**
224      * Schedule the clock sync job
225      */
226     private void scheduleClockSyncJob() {
227         ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
228         if (clockSyncJob == null || clockSyncJob.isCancelled()) {
229             clockSyncJob = null;
230             this.clockSyncJob = scheduler.schedule(this::syncThermostatClock, 1, TimeUnit.MINUTES);
231         }
232     }
233
234     /**
235      * Sync the thermostat's clock with the host system clock
236      */
237     private void syncThermostatClock() {
238         boolean success = false;
239
240         // don't sync clock if override is on because it will reset temporary hold
241         final Integer override = rthermData.getThermostatData().getOverride();
242         if (override == null || override.compareTo(0) == 0) {
243             Calendar c = Calendar.getInstance();
244
245             // The thermostat week starts as Monday = 0, subtract 2 since in standard DoW Monday = 2
246             int thermDayOfWeek = c.get(Calendar.DAY_OF_WEEK) - 2;
247             // Sunday will be -1, so add 7 to make it 6
248             if (thermDayOfWeek < 0) {
249                 thermDayOfWeek += 7;
250             }
251
252             success = connector.sendCommand(null, null,
253                     String.format(JSON_TIME, thermDayOfWeek, c.get(Calendar.HOUR_OF_DAY), c.get(Calendar.MINUTE)),
254                     TIME_RESOURCE).contains("success");
255         }
256
257         // if sync call was successful run again in one hour, if un-successful try again in one minute
258         this.clockSyncJob = scheduler.schedule(this::syncThermostatClock, (success ? 60 : 1), TimeUnit.MINUTES);
259     }
260
261     /**
262      * Start the job to periodically update humidity and runtime date from the thermostat
263      */
264     private void startAutomaticLogRefresh() {
265         ScheduledFuture<?> logRefreshJob = this.logRefreshJob;
266         if (logRefreshJob == null || logRefreshJob.isCancelled()) {
267             Runnable runnable = () -> {
268                 // Request humidity data from the thermostat if we are a CT80
269                 if (this.isCT80) {
270                     // send an async call to the thermostat to get the humidity data
271                     connector.getAsyncThermostatData(HUMIDITY_RESOURCE);
272                 }
273
274                 if (!this.disableLogs) {
275                     // send an async call to the thermostat to get the runtime data
276                     connector.getAsyncThermostatData(RUNTIME_RESOURCE);
277                 }
278             };
279
280             logRefreshJob = null;
281             this.logRefreshJob = scheduler.scheduleWithFixedDelay(runnable, (!this.clockSync ? 1 : 2), logRefreshPeriod,
282                     TimeUnit.MINUTES);
283         }
284     }
285
286     @Override
287     public void dispose() {
288         logger.debug("Disposing the RadioThermostat handler.");
289         connector.removeEventListener(this);
290
291         ScheduledFuture<?> refreshJob = this.refreshJob;
292         if (refreshJob != null) {
293             refreshJob.cancel(true);
294             this.refreshJob = null;
295         }
296
297         ScheduledFuture<?> logRefreshJob = this.logRefreshJob;
298         if (logRefreshJob != null) {
299             logRefreshJob.cancel(true);
300             this.logRefreshJob = null;
301         }
302
303         ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
304         if (clockSyncJob != null) {
305             clockSyncJob.cancel(true);
306             this.clockSyncJob = null;
307         }
308     }
309
310     public void handleRawCommand(@Nullable String rawCommand) {
311         connector.sendCommand(null, null, rawCommand, DEFAULT_RESOURCE);
312     }
313
314     public void handleRawCommand(@Nullable String rawCommand, String resource) {
315         connector.sendCommand(null, null, rawCommand, resource);
316     }
317
318     @Override
319     public void handleCommand(ChannelUID channelUID, Command command) {
320         if (command instanceof RefreshType) {
321             updateChannel(channelUID.getId(), rthermData);
322         } else {
323             Integer cmdInt = -1;
324             String cmdStr = command.toString();
325             try {
326                 // parse out an Integer from the string
327                 // ie '70.5 F' becomes 70, also handles negative numbers
328                 cmdInt = NumberFormat.getInstance().parse(cmdStr).intValue();
329             } catch (ParseException e) {
330                 logger.debug("Command: {} -> Not an integer", cmdStr);
331             }
332
333             switch (channelUID.getId()) {
334                 case MODE:
335                     // only do if commanded mode is different than current mode
336                     if (!cmdInt.equals(rthermData.getThermostatData().getMode())) {
337                         connector.sendCommand("tmode", cmdStr, DEFAULT_RESOURCE);
338
339                         // set the new operating mode, reset everything else,
340                         // because refreshing the tstat data below is really slow.
341                         rthermData.getThermostatData().setMode(cmdInt);
342                         rthermData.getThermostatData().setHeatTarget(Double.valueOf(0));
343                         rthermData.getThermostatData().setCoolTarget(Double.valueOf(0));
344                         updateChannel(SET_POINT, rthermData);
345                         rthermData.getThermostatData().setHold(0);
346                         updateChannel(HOLD, rthermData);
347                         rthermData.getThermostatData().setProgramMode(-1);
348                         updateChannel(PROGRAM_MODE, rthermData);
349
350                         // now just trigger a refresh of the thermostat to get the new active setpoint
351                         // this takes a while for the JSON request to complete (async).
352                         connector.getAsyncThermostatData(DEFAULT_RESOURCE);
353                     }
354                     break;
355                 case FAN_MODE:
356                     rthermData.getThermostatData().setFanMode(cmdInt);
357                     connector.sendCommand("fmode", cmdStr, DEFAULT_RESOURCE);
358                     break;
359                 case PROGRAM_MODE:
360                     rthermData.getThermostatData().setProgramMode(cmdInt);
361                     connector.sendCommand("program_mode", cmdStr, DEFAULT_RESOURCE);
362                     break;
363                 case HOLD:
364                     if (command instanceof OnOffType && command == OnOffType.ON) {
365                         rthermData.getThermostatData().setHold(1);
366                         connector.sendCommand("hold", "1", DEFAULT_RESOURCE);
367                     } else if (command instanceof OnOffType && command == OnOffType.OFF) {
368                         rthermData.getThermostatData().setHold(0);
369                         connector.sendCommand("hold", "0", DEFAULT_RESOURCE);
370                     }
371                     break;
372                 case SET_POINT:
373                     String cmdKey;
374                     if (rthermData.getThermostatData().getMode() == 1) {
375                         cmdKey = this.setpointCmdKeyPrefix + "heat";
376                         rthermData.getThermostatData().setHeatTarget(Double.valueOf(cmdInt));
377                         rthermData.getThermostatData().setOverride(1);
378                     } else if (rthermData.getThermostatData().getMode() == 2) {
379                         cmdKey = this.setpointCmdKeyPrefix + "cool";
380                         rthermData.getThermostatData().setCoolTarget(Double.valueOf(cmdInt));
381                         rthermData.getThermostatData().setOverride(1);
382                     } else {
383                         // don't do anything if we are not in heat or cool mode
384                         break;
385                     }
386                     connector.sendCommand(cmdKey, cmdInt.toString(), DEFAULT_RESOURCE);
387                     break;
388                 case REMOTE_TEMP:
389                     if (cmdInt != -1) {
390                         QuantityType<?> remoteTemp = ((QuantityType<Temperature>) command)
391                                 .toUnit(ImperialUnits.FAHRENHEIT);
392                         connector.sendCommand("rem_temp", String.valueOf(remoteTemp.intValue()), REMOTE_TEMP_RESOURCE);
393                     } else {
394                         connector.sendCommand("rem_mode", "0", REMOTE_TEMP_RESOURCE);
395                     }
396                     break;
397                 case MESSAGE:
398                     if (!cmdStr.isEmpty()) {
399                         connector.sendCommand(null, null, String.format(JSON_PMA, cmdStr), PMA_RESOURCE);
400                     } else {
401                         connector.sendCommand("mode", "0", PMA_RESOURCE);
402                     }
403                     break;
404                 default:
405                     logger.warn("Unsupported command: {}", command.toString());
406             }
407         }
408     }
409
410     /**
411      * Handle a RadioThermostat event received from the listeners
412      *
413      * @param event the event received from the listeners
414      */
415     @Override
416     public void onNewMessageEvent(RadioThermostatEvent event) {
417         logger.debug("onNewMessageEvent: key {} = {}", event.getKey(), event.getValue());
418
419         String evtKey = event.getKey();
420         String evtVal = event.getValue();
421
422         if (KEY_ERROR.equals(evtKey)) {
423             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
424                     "@text/offline.communication-error-get-data");
425         } else {
426             updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
427
428             // Map the JSON response to the correct object and update appropriate channels
429             switch (evtKey) {
430                 case DEFAULT_RESOURCE:
431                     rthermData.setThermostatData(gson.fromJson(evtVal, RadioThermostatTstatDTO.class));
432                     // if thermostat returned -1 for temperature, skip this update
433                     if (rthermData.getThermostatData().getTemperature() >= 0) {
434                         updateAllChannels();
435                     }
436                     break;
437                 case HUMIDITY_RESOURCE:
438                     RadioThermostatHumidityDTO dto = gson.fromJson(evtVal, RadioThermostatHumidityDTO.class);
439                     // if thermostat returned -1 for humidity, skip this update
440                     if (dto != null && dto.getHumidity() >= 0) {
441                         rthermData.setHumidity(dto.getHumidity());
442                         updateChannel(HUMIDITY, rthermData);
443                     }
444                     break;
445                 case RUNTIME_RESOURCE:
446                     rthermData.setRuntime(gson.fromJson(evtVal, RadioThermostatRuntimeDTO.class));
447                     updateChannel(TODAY_HEAT_RUNTIME, rthermData);
448                     updateChannel(TODAY_COOL_RUNTIME, rthermData);
449                     updateChannel(YESTERDAY_HEAT_RUNTIME, rthermData);
450                     updateChannel(YESTERDAY_COOL_RUNTIME, rthermData);
451                     break;
452             }
453         }
454     }
455
456     /**
457      * Update the channel from the last Thermostat data retrieved
458      *
459      * @param channelId the id identifying the channel to be updated
460      */
461     private void updateChannel(String channelId, RadioThermostatDTO rthermData) {
462         if (isLinked(channelId)) {
463             Object value;
464             try {
465                 value = getValue(channelId, rthermData);
466             } catch (Exception e) {
467                 logger.debug("Error setting {} value", channelId.toUpperCase());
468                 return;
469             }
470
471             State state = null;
472             if (value == null) {
473                 state = UnDefType.UNDEF;
474             } else if (value instanceof PointType) {
475                 state = (PointType) value;
476             } else if (value instanceof ZonedDateTime) {
477                 state = new DateTimeType((ZonedDateTime) value);
478             } else if (value instanceof QuantityType<?>) {
479                 state = (QuantityType<?>) value;
480             } else if (value instanceof Number) {
481                 state = new DecimalType((Number) value);
482             } else if (value instanceof String) {
483                 state = new StringType(value.toString());
484             } else if (value instanceof OnOffType) {
485                 state = (OnOffType) value;
486             } else {
487                 logger.warn("Update channel {}: Unsupported value type {}", channelId,
488                         value.getClass().getSimpleName());
489             }
490             logger.debug("Update channel {} with state {} ({})", channelId, (state == null) ? "null" : state.toString(),
491                     (value == null) ? "null" : value.getClass().getSimpleName());
492
493             // Update the channel
494             if (state != null) {
495                 updateState(channelId, state);
496             }
497         }
498     }
499
500     /**
501      * Update a given channelId from the thermostat data
502      *
503      * @param the channel id to be updated
504      * @param data the RadioThermostat dto
505      * @return the value to be set in the state
506      */
507     public static @Nullable Object getValue(String channelId, RadioThermostatDTO data) {
508         switch (channelId) {
509             case TEMPERATURE:
510                 if (data.getThermostatData().getTemperature() != null) {
511                     return new QuantityType<Temperature>(data.getThermostatData().getTemperature(),
512                             API_TEMPERATURE_UNIT);
513                 } else {
514                     return null;
515                 }
516             case HUMIDITY:
517                 if (data.getHumidity() != null) {
518                     return new QuantityType<>(data.getHumidity(), API_HUMIDITY_UNIT);
519                 } else {
520                     return null;
521                 }
522             case MODE:
523                 return data.getThermostatData().getMode();
524             case FAN_MODE:
525                 return data.getThermostatData().getFanMode();
526             case PROGRAM_MODE:
527                 return data.getThermostatData().getProgramMode();
528             case SET_POINT:
529                 if (data.getThermostatData().getSetpoint() != 0) {
530                     return new QuantityType<Temperature>(data.getThermostatData().getSetpoint(), API_TEMPERATURE_UNIT);
531                 } else {
532                     return null;
533                 }
534             case OVERRIDE:
535                 return data.getThermostatData().getOverride();
536             case HOLD:
537                 return OnOffType.from(data.getThermostatData().getHold() == 1);
538             case STATUS:
539                 return data.getThermostatData().getStatus();
540             case FAN_STATUS:
541                 // workaround for some thermostats that don't report that the fan is on during heating or cooling
542                 if (data.getThermostatData().getStatus() > 0) {
543                     return 1;
544                 } else {
545                     return data.getThermostatData().getFanStatus();
546                 }
547             case DAY:
548                 return data.getThermostatData().getTime().getDayOfWeek();
549             case HOUR:
550                 return data.getThermostatData().getTime().getHour();
551             case MINUTE:
552                 return data.getThermostatData().getTime().getMinute();
553             case DATE_STAMP:
554                 return data.getThermostatData().getTime().getThemostatDateTime();
555             case TODAY_HEAT_RUNTIME:
556                 return new QuantityType<>(data.getRuntime().getToday().getHeatTime().getRuntime(), API_MINUTES_UNIT);
557             case TODAY_COOL_RUNTIME:
558                 return new QuantityType<>(data.getRuntime().getToday().getCoolTime().getRuntime(), API_MINUTES_UNIT);
559             case YESTERDAY_HEAT_RUNTIME:
560                 return new QuantityType<>(data.getRuntime().getYesterday().getHeatTime().getRuntime(),
561                         API_MINUTES_UNIT);
562             case YESTERDAY_COOL_RUNTIME:
563                 return new QuantityType<>(data.getRuntime().getYesterday().getCoolTime().getRuntime(),
564                         API_MINUTES_UNIT);
565         }
566         return null;
567     }
568
569     /**
570      * Updates all channels from rthermData
571      */
572     private void updateAllChannels() {
573         // Update all channels from rthermData
574         getThing().getChannels().forEach(channel -> {
575             if (!NO_UPDATE_CHANNEL_IDS.contains(channel.getUID().getId())) {
576                 updateChannel(channel.getUID().getId(), rthermData);
577             }
578         });
579     }
580
581     /**
582      * Build a list of fan modes based on what model thermostat is used
583      *
584      * @return list of state options for thermostat fan modes
585      */
586     private List<StateOption> getFanModeOptions() {
587         List<StateOption> fanModeOptions = new ArrayList<>();
588
589         fanModeOptions.add(new StateOption("0", "@text/options.fan-option-auto"));
590         if (this.isCT80) {
591             fanModeOptions.add(new StateOption("1", "@text/options.fan-option-circulate"));
592         }
593         fanModeOptions.add(new StateOption("2", "@text/options.fan-option-on"));
594
595         return fanModeOptions;
596     }
597 }