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