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