]> git.basschouten.com Git - openhab-addons.git/blob
ed8e5827f30e2870c7bec4333d7325665577539b
[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         Calendar c = Calendar.getInstance();
239
240         // The thermostat week starts as Monday = 0, subtract 2 since in standard DoW Monday = 2
241         int thermDayOfWeek = c.get(Calendar.DAY_OF_WEEK) - 2;
242         // Sunday will be -1, so add 7 to make it 6
243         if (thermDayOfWeek < 0) {
244             thermDayOfWeek += 7;
245         }
246
247         final String response = connector.sendCommand(null, null,
248                 String.format(JSON_TIME, thermDayOfWeek, c.get(Calendar.HOUR_OF_DAY), c.get(Calendar.MINUTE)),
249                 TIME_RESOURCE);
250
251         // if sync call was successful run again in one hour, if un-successful try again in one minute
252         this.clockSyncJob = scheduler.schedule(this::syncThermostatClock, (response.contains("success") ? 60 : 1),
253                 TimeUnit.MINUTES);
254     }
255
256     /**
257      * Start the job to periodically update humidity and runtime date from the thermostat
258      */
259     private void startAutomaticLogRefresh() {
260         ScheduledFuture<?> logRefreshJob = this.logRefreshJob;
261         if (logRefreshJob == null || logRefreshJob.isCancelled()) {
262             Runnable runnable = () -> {
263                 // Request humidity data from the thermostat if we are a CT80
264                 if (this.isCT80) {
265                     // send an async call to the thermostat to get the humidity data
266                     connector.getAsyncThermostatData(HUMIDITY_RESOURCE);
267                 }
268
269                 if (!this.disableLogs) {
270                     // send an async call to the thermostat to get the runtime data
271                     connector.getAsyncThermostatData(RUNTIME_RESOURCE);
272                 }
273             };
274
275             logRefreshJob = null;
276             this.logRefreshJob = scheduler.scheduleWithFixedDelay(runnable, (!this.clockSync ? 1 : 2), logRefreshPeriod,
277                     TimeUnit.MINUTES);
278         }
279     }
280
281     @Override
282     public void dispose() {
283         logger.debug("Disposing the RadioThermostat handler.");
284         connector.removeEventListener(this);
285
286         ScheduledFuture<?> refreshJob = this.refreshJob;
287         if (refreshJob != null) {
288             refreshJob.cancel(true);
289             this.refreshJob = null;
290         }
291
292         ScheduledFuture<?> logRefreshJob = this.logRefreshJob;
293         if (logRefreshJob != null) {
294             logRefreshJob.cancel(true);
295             this.logRefreshJob = null;
296         }
297
298         ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
299         if (clockSyncJob != null) {
300             clockSyncJob.cancel(true);
301             this.clockSyncJob = null;
302         }
303     }
304
305     public void handleRawCommand(@Nullable String rawCommand) {
306         connector.sendCommand(null, null, rawCommand, DEFAULT_RESOURCE);
307     }
308
309     public void handleRawCommand(@Nullable String rawCommand, String resource) {
310         connector.sendCommand(null, null, rawCommand, resource);
311     }
312
313     @Override
314     public void handleCommand(ChannelUID channelUID, Command command) {
315         if (command instanceof RefreshType) {
316             updateChannel(channelUID.getId(), rthermData);
317         } else {
318             Integer cmdInt = -1;
319             String cmdStr = command.toString();
320             try {
321                 // parse out an Integer from the string
322                 // ie '70.5 F' becomes 70, also handles negative numbers
323                 cmdInt = NumberFormat.getInstance().parse(cmdStr).intValue();
324             } catch (ParseException e) {
325                 logger.debug("Command: {} -> Not an integer", cmdStr);
326             }
327
328             switch (channelUID.getId()) {
329                 case MODE:
330                     // only do if commanded mode is different than current mode
331                     if (!cmdInt.equals(rthermData.getThermostatData().getMode())) {
332                         connector.sendCommand("tmode", cmdStr, DEFAULT_RESOURCE);
333
334                         // set the new operating mode, reset everything else,
335                         // because refreshing the tstat data below is really slow.
336                         rthermData.getThermostatData().setMode(cmdInt);
337                         rthermData.getThermostatData().setHeatTarget(Double.valueOf(0));
338                         rthermData.getThermostatData().setCoolTarget(Double.valueOf(0));
339                         updateChannel(SET_POINT, rthermData);
340                         rthermData.getThermostatData().setHold(0);
341                         updateChannel(HOLD, rthermData);
342                         rthermData.getThermostatData().setProgramMode(-1);
343                         updateChannel(PROGRAM_MODE, rthermData);
344
345                         // now just trigger a refresh of the thermostat to get the new active setpoint
346                         // this takes a while for the JSON request to complete (async).
347                         connector.getAsyncThermostatData(DEFAULT_RESOURCE);
348                     }
349                     break;
350                 case FAN_MODE:
351                     rthermData.getThermostatData().setFanMode(cmdInt);
352                     connector.sendCommand("fmode", cmdStr, DEFAULT_RESOURCE);
353                     break;
354                 case PROGRAM_MODE:
355                     rthermData.getThermostatData().setProgramMode(cmdInt);
356                     connector.sendCommand("program_mode", cmdStr, DEFAULT_RESOURCE);
357                     break;
358                 case HOLD:
359                     if (command instanceof OnOffType && command == OnOffType.ON) {
360                         rthermData.getThermostatData().setHold(1);
361                         connector.sendCommand("hold", "1", DEFAULT_RESOURCE);
362                     } else if (command instanceof OnOffType && command == OnOffType.OFF) {
363                         rthermData.getThermostatData().setHold(0);
364                         connector.sendCommand("hold", "0", DEFAULT_RESOURCE);
365                     }
366                     break;
367                 case SET_POINT:
368                     String cmdKey = null;
369                     if (rthermData.getThermostatData().getMode() == 1) {
370                         cmdKey = this.setpointCmdKeyPrefix + "heat";
371                         rthermData.getThermostatData().setHeatTarget(Double.valueOf(cmdInt));
372                     } else if (rthermData.getThermostatData().getMode() == 2) {
373                         cmdKey = this.setpointCmdKeyPrefix + "cool";
374                         rthermData.getThermostatData().setCoolTarget(Double.valueOf(cmdInt));
375                     } else {
376                         // don't do anything if we are not in heat or cool mode
377                         break;
378                     }
379                     connector.sendCommand(cmdKey, cmdInt.toString(), DEFAULT_RESOURCE);
380                     break;
381                 case REMOTE_TEMP:
382                     if (cmdInt != -1) {
383                         QuantityType<?> remoteTemp = ((QuantityType<Temperature>) command)
384                                 .toUnit(ImperialUnits.FAHRENHEIT);
385                         connector.sendCommand("rem_temp", String.valueOf(remoteTemp.intValue()), REMOTE_TEMP_RESOURCE);
386                     } else {
387                         connector.sendCommand("rem_mode", "0", REMOTE_TEMP_RESOURCE);
388                     }
389                     break;
390                 case MESSAGE:
391                     if (!cmdStr.isEmpty()) {
392                         connector.sendCommand(null, null, String.format(JSON_PMA, cmdStr), PMA_RESOURCE);
393                     } else {
394                         connector.sendCommand("mode", "0", PMA_RESOURCE);
395                     }
396                     break;
397                 default:
398                     logger.warn("Unsupported command: {}", command.toString());
399             }
400         }
401     }
402
403     /**
404      * Handle a RadioThermostat event received from the listeners
405      *
406      * @param event the event received from the listeners
407      */
408     @Override
409     public void onNewMessageEvent(RadioThermostatEvent event) {
410         logger.debug("onNewMessageEvent: key {} = {}", event.getKey(), event.getValue());
411
412         String evtKey = event.getKey();
413         String evtVal = event.getValue();
414
415         if (KEY_ERROR.equals(evtKey)) {
416             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
417                     "@text/offline.communication-error-get-data");
418         } else {
419             updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
420
421             // Map the JSON response to the correct object and update appropriate channels
422             switch (evtKey) {
423                 case DEFAULT_RESOURCE:
424                     rthermData.setThermostatData(gson.fromJson(evtVal, RadioThermostatTstatDTO.class));
425                     // if thermostat returned -1 for temperature, skip this update
426                     if (rthermData.getThermostatData().getTemperature() >= 0) {
427                         updateAllChannels();
428                     }
429                     break;
430                 case HUMIDITY_RESOURCE:
431                     RadioThermostatHumidityDTO dto = gson.fromJson(evtVal, RadioThermostatHumidityDTO.class);
432                     // if thermostat returned -1 for humidity, skip this update
433                     if (dto != null && dto.getHumidity() >= 0) {
434                         rthermData.setHumidity(dto.getHumidity());
435                         updateChannel(HUMIDITY, rthermData);
436                     }
437                     break;
438                 case RUNTIME_RESOURCE:
439                     rthermData.setRuntime(gson.fromJson(evtVal, RadioThermostatRuntimeDTO.class));
440                     updateChannel(TODAY_HEAT_RUNTIME, rthermData);
441                     updateChannel(TODAY_COOL_RUNTIME, rthermData);
442                     updateChannel(YESTERDAY_HEAT_RUNTIME, rthermData);
443                     updateChannel(YESTERDAY_COOL_RUNTIME, rthermData);
444                     break;
445             }
446         }
447     }
448
449     /**
450      * Update the channel from the last Thermostat data retrieved
451      *
452      * @param channelId the id identifying the channel to be updated
453      */
454     private void updateChannel(String channelId, RadioThermostatDTO rthermData) {
455         if (isLinked(channelId)) {
456             Object value;
457             try {
458                 value = getValue(channelId, rthermData);
459             } catch (Exception e) {
460                 logger.debug("Error setting {} value", channelId.toUpperCase());
461                 return;
462             }
463
464             State state = null;
465             if (value == null) {
466                 state = UnDefType.UNDEF;
467             } else if (value instanceof PointType) {
468                 state = (PointType) value;
469             } else if (value instanceof ZonedDateTime) {
470                 state = new DateTimeType((ZonedDateTime) value);
471             } else if (value instanceof QuantityType<?>) {
472                 state = (QuantityType<?>) value;
473             } else if (value instanceof Number) {
474                 state = new DecimalType((Number) value);
475             } else if (value instanceof String) {
476                 state = new StringType(value.toString());
477             } else if (value instanceof OnOffType) {
478                 state = (OnOffType) value;
479             } else {
480                 logger.warn("Update channel {}: Unsupported value type {}", channelId,
481                         value.getClass().getSimpleName());
482             }
483             logger.debug("Update channel {} with state {} ({})", channelId, (state == null) ? "null" : state.toString(),
484                     (value == null) ? "null" : value.getClass().getSimpleName());
485
486             // Update the channel
487             if (state != null) {
488                 updateState(channelId, state);
489             }
490         }
491     }
492
493     /**
494      * Update a given channelId from the thermostat data
495      *
496      * @param the channel id to be updated
497      * @param data the RadioThermostat dto
498      * @return the value to be set in the state
499      */
500     public static @Nullable Object getValue(String channelId, RadioThermostatDTO data) {
501         switch (channelId) {
502             case TEMPERATURE:
503                 if (data.getThermostatData().getTemperature() != null) {
504                     return new QuantityType<Temperature>(data.getThermostatData().getTemperature(),
505                             API_TEMPERATURE_UNIT);
506                 } else {
507                     return null;
508                 }
509             case HUMIDITY:
510                 if (data.getHumidity() != null) {
511                     return new QuantityType<>(data.getHumidity(), API_HUMIDITY_UNIT);
512                 } else {
513                     return null;
514                 }
515             case MODE:
516                 return data.getThermostatData().getMode();
517             case FAN_MODE:
518                 return data.getThermostatData().getFanMode();
519             case PROGRAM_MODE:
520                 return data.getThermostatData().getProgramMode();
521             case SET_POINT:
522                 if (data.getThermostatData().getSetpoint() != 0) {
523                     return new QuantityType<Temperature>(data.getThermostatData().getSetpoint(), API_TEMPERATURE_UNIT);
524                 } else {
525                     return null;
526                 }
527             case OVERRIDE:
528                 return data.getThermostatData().getOverride();
529             case HOLD:
530                 return OnOffType.from(data.getThermostatData().getHold() == 1);
531             case STATUS:
532                 return data.getThermostatData().getStatus();
533             case FAN_STATUS:
534                 // workaround for some thermostats that don't report that the fan is on during heating or cooling
535                 if (data.getThermostatData().getStatus() > 0) {
536                     return 1;
537                 } else {
538                     return data.getThermostatData().getFanStatus();
539                 }
540             case DAY:
541                 return data.getThermostatData().getTime().getDayOfWeek();
542             case HOUR:
543                 return data.getThermostatData().getTime().getHour();
544             case MINUTE:
545                 return data.getThermostatData().getTime().getMinute();
546             case DATE_STAMP:
547                 return data.getThermostatData().getTime().getThemostatDateTime();
548             case TODAY_HEAT_RUNTIME:
549                 return new QuantityType<>(data.getRuntime().getToday().getHeatTime().getRuntime(), API_MINUTES_UNIT);
550             case TODAY_COOL_RUNTIME:
551                 return new QuantityType<>(data.getRuntime().getToday().getCoolTime().getRuntime(), API_MINUTES_UNIT);
552             case YESTERDAY_HEAT_RUNTIME:
553                 return new QuantityType<>(data.getRuntime().getYesterday().getHeatTime().getRuntime(),
554                         API_MINUTES_UNIT);
555             case YESTERDAY_COOL_RUNTIME:
556                 return new QuantityType<>(data.getRuntime().getYesterday().getCoolTime().getRuntime(),
557                         API_MINUTES_UNIT);
558         }
559         return null;
560     }
561
562     /**
563      * Updates all channels from rthermData
564      */
565     private void updateAllChannels() {
566         // Update all channels from rthermData
567         getThing().getChannels().forEach(channel -> {
568             if (!NO_UPDATE_CHANNEL_IDS.contains(channel.getUID().getId())) {
569                 updateChannel(channel.getUID().getId(), rthermData);
570             }
571         });
572     }
573
574     /**
575      * Build a list of fan modes based on what model thermostat is used
576      *
577      * @return list of state options for thermostat fan modes
578      */
579     private List<StateOption> getFanModeOptions() {
580         List<StateOption> fanModeOptions = new ArrayList<>();
581
582         fanModeOptions.add(new StateOption("0", "@text/options.fan-option-auto"));
583         if (this.isCT80) {
584             fanModeOptions.add(new StateOption("1", "@text/options.fan-option-circulate"));
585         }
586         fanModeOptions.add(new StateOption("2", "@text/options.fan-option-on"));
587
588         return fanModeOptions;
589     }
590 }