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