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