]> git.basschouten.com Git - openhab-addons.git/blob
836c593b02e1d666182238bf82631fac1e6d9e79
[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.sensibo.internal.handler;
14
15 import static org.openhab.binding.sensibo.internal.SensiboBindingConstants.*;
16
17 import java.util.ArrayList;
18 import java.util.Collection;
19 import java.util.Collections;
20 import java.util.HashMap;
21 import java.util.List;
22 import java.util.Locale;
23 import java.util.Map;
24 import java.util.Objects;
25 import java.util.Optional;
26 import java.util.Set;
27 import java.util.stream.Collectors;
28
29 import javax.measure.IncommensurableException;
30 import javax.measure.UnconvertibleException;
31 import javax.measure.Unit;
32 import javax.measure.UnitConverter;
33 import javax.measure.quantity.Temperature;
34
35 import org.apache.commons.lang3.StringUtils;
36 import org.apache.commons.lang3.builder.ToStringBuilder;
37 import org.apache.commons.lang3.builder.ToStringStyle;
38 import org.apache.commons.lang3.text.WordUtils;
39 import org.eclipse.jdt.annotation.NonNullByDefault;
40 import org.eclipse.jdt.annotation.Nullable;
41 import org.openhab.binding.sensibo.internal.CallbackChannelsTypeProvider;
42 import org.openhab.binding.sensibo.internal.SensiboBindingConstants;
43 import org.openhab.binding.sensibo.internal.config.SensiboSkyConfiguration;
44 import org.openhab.binding.sensibo.internal.dto.poddetails.TemperatureDTO;
45 import org.openhab.binding.sensibo.internal.model.SensiboModel;
46 import org.openhab.binding.sensibo.internal.model.SensiboSky;
47 import org.openhab.core.library.types.DecimalType;
48 import org.openhab.core.library.types.OnOffType;
49 import org.openhab.core.library.types.QuantityType;
50 import org.openhab.core.library.types.StringType;
51 import org.openhab.core.library.unit.SIUnits;
52 import org.openhab.core.library.unit.Units;
53 import org.openhab.core.thing.Channel;
54 import org.openhab.core.thing.ChannelUID;
55 import org.openhab.core.thing.Thing;
56 import org.openhab.core.thing.ThingStatus;
57 import org.openhab.core.thing.ThingStatusDetail;
58 import org.openhab.core.thing.binding.ThingHandlerService;
59 import org.openhab.core.thing.binding.builder.ChannelBuilder;
60 import org.openhab.core.thing.type.ChannelType;
61 import org.openhab.core.thing.type.ChannelTypeBuilder;
62 import org.openhab.core.thing.type.ChannelTypeProvider;
63 import org.openhab.core.thing.type.ChannelTypeUID;
64 import org.openhab.core.thing.type.StateChannelTypeBuilder;
65 import org.openhab.core.types.Command;
66 import org.openhab.core.types.RefreshType;
67 import org.openhab.core.types.StateDescriptionFragmentBuilder;
68 import org.openhab.core.types.StateOption;
69 import org.openhab.core.types.UnDefType;
70 import org.slf4j.Logger;
71 import org.slf4j.LoggerFactory;
72
73 /**
74  * The {@link SensiboSkyHandler} is responsible for handling commands, which are
75  * sent to one of the channels.
76  *
77  * @author Arne Seime - Initial contribution
78  */
79 @NonNullByDefault
80 public class SensiboSkyHandler extends SensiboBaseThingHandler implements ChannelTypeProvider {
81     public static final String SWING_PROPERTY = "swing";
82     public static final String MASTER_SWITCH_PROPERTY = "on";
83     public static final String FAN_LEVEL_PROPERTY = "fanLevel";
84     public static final String MODE_PROPERTY = "mode";
85     public static final String TARGET_TEMPERATURE_PROPERTY = "targetTemperature";
86     public static final String SWING_MODE_LABEL = "Swing Mode";
87     public static final String FAN_LEVEL_LABEL = "Fan Level";
88     public static final String MODE_LABEL = "Mode";
89     public static final String TARGET_TEMPERATURE_LABEL = "Target Temperature";
90     private static final String ITEM_TYPE_STRING = "String";
91     private static final String ITEM_TYPE_NUMBER_TEMPERATURE = "Number:Temperature";
92     private final Logger logger = LoggerFactory.getLogger(SensiboSkyHandler.class);
93     private final Map<ChannelTypeUID, ChannelType> generatedChannelTypes = new HashMap<>();
94     private Optional<SensiboSkyConfiguration> config = Optional.empty();
95
96     public SensiboSkyHandler(final Thing thing) {
97         super(thing);
98     }
99
100     private static String beautify(final String camelCaseWording) {
101         final StringBuilder b = new StringBuilder();
102         for (final String s : StringUtils.splitByCharacterTypeCamelCase(camelCaseWording)) {
103             b.append(" ");
104             b.append(s);
105         }
106         final StringBuilder bs = new StringBuilder();
107         for (final String t : StringUtils.splitByWholeSeparator(b.toString(), " _")) {
108             bs.append(" ");
109             bs.append(t);
110         }
111
112         return WordUtils.capitalizeFully(bs.toString()).trim();
113     }
114
115     private String getMacAddress() {
116         if (config.isPresent()) {
117             return config.get().macAddress;
118         }
119         throw new IllegalArgumentException("No configuration present");
120     }
121
122     @Override
123     public void handleCommand(final ChannelUID channelUID, final Command command) {
124         handleCommand(channelUID, command, getSensiboModel());
125     }
126
127     /*
128      * Package private in order to be reachable from unit test
129      */
130     void updateAcState(SensiboSky sensiboSky, String property, Object value) {
131         StateChange stateChange = checkStateChangeValid(sensiboSky, property, value);
132         if (stateChange.valid) {
133             getAccountHandler().ifPresent(
134                     handler -> handler.updateSensiboSkyAcState(getMacAddress(), property, stateChange.value, this));
135         } else {
136             logger.info("Update command not sent; invalid state change for SensiboSky AC state: {}",
137                     stateChange.validationMessage);
138         }
139     }
140
141     private void updateTimer(@Nullable Integer secondsFromNowUntilSwitchOff) {
142         getAccountHandler()
143                 .ifPresent(handler -> handler.updateSensiboSkyTimer(getMacAddress(), secondsFromNowUntilSwitchOff));
144     }
145
146     @Override
147     protected void handleCommand(final ChannelUID channelUID, final Command command, final SensiboModel model) {
148         model.findSensiboSkyByMacAddress(getMacAddress()).ifPresent(sensiboSky -> {
149             if (sensiboSky.isAlive()) {
150                 if (getThing().getStatus() != ThingStatus.ONLINE) {
151                     addDynamicChannelsAndProperties(sensiboSky);
152                     updateStatus(ThingStatus.ONLINE); // In case it has been offline
153                 }
154                 switch (channelUID.getId()) {
155                     case CHANNEL_CURRENT_HUMIDITY:
156                         handleCurrentHumidityCommand(channelUID, command, sensiboSky);
157                         break;
158                     case CHANNEL_CURRENT_TEMPERATURE:
159                         handleCurrentTemperatureCommand(channelUID, command, sensiboSky);
160                         break;
161                     case CHANNEL_MASTER_SWITCH:
162                         handleMasterSwitchCommand(channelUID, command, sensiboSky);
163                         break;
164                     case CHANNEL_TARGET_TEMPERATURE:
165                         handleTargetTemperatureCommand(channelUID, command, sensiboSky);
166                         break;
167                     case CHANNEL_MODE:
168                         handleModeCommand(channelUID, command, sensiboSky);
169                         break;
170                     case CHANNEL_SWING_MODE:
171                         handleSwingCommand(channelUID, command, sensiboSky);
172                         break;
173                     case CHANNEL_FAN_LEVEL:
174                         handleFanLevelCommand(channelUID, command, sensiboSky);
175                         break;
176                     case CHANNEL_TIMER:
177                         handleTimerCommand(channelUID, command, sensiboSky);
178                         break;
179                     default:
180                         logger.debug("Received command on unknown channel {}, ignoring", channelUID.getId());
181                 }
182             } else {
183                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
184                         "Unreachable by Sensibo servers");
185             }
186         });
187     }
188
189     private void handleTimerCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
190         if (command instanceof RefreshType) {
191             if (sensiboSky.getTimer().isPresent() && sensiboSky.getTimer().get().secondsRemaining > 0) {
192                 updateState(channelUID, new DecimalType(sensiboSky.getTimer().get().secondsRemaining));
193             } else {
194                 updateState(channelUID, UnDefType.UNDEF);
195             }
196         } else if (command instanceof DecimalType) {
197             final DecimalType newValue = (DecimalType) command;
198             updateTimer(newValue.intValue());
199         } else {
200             updateTimer(null);
201         }
202     }
203
204     private void handleFanLevelCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
205         if (command instanceof RefreshType) {
206             if (sensiboSky.getAcState().isPresent() && sensiboSky.getAcState().get().getFanLevel() != null) {
207                 updateState(channelUID, new StringType(sensiboSky.getAcState().get().getFanLevel()));
208             } else {
209                 updateState(channelUID, UnDefType.UNDEF);
210             }
211         } else if (command instanceof StringType) {
212             final StringType newValue = (StringType) command;
213             updateAcState(sensiboSky, FAN_LEVEL_PROPERTY, newValue.toString());
214         }
215     }
216
217     private void handleSwingCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
218         if (command instanceof RefreshType && sensiboSky.getAcState().isPresent()) {
219             if (sensiboSky.getAcState().isPresent() && sensiboSky.getAcState().get().getSwing() != null) {
220                 updateState(channelUID, new StringType(sensiboSky.getAcState().get().getSwing()));
221             } else {
222                 updateState(channelUID, UnDefType.UNDEF);
223             }
224         } else if (command instanceof StringType) {
225             final StringType newValue = (StringType) command;
226             updateAcState(sensiboSky, SWING_PROPERTY, newValue.toString());
227         }
228     }
229
230     private void handleModeCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
231         if (command instanceof RefreshType) {
232             if (sensiboSky.getAcState().isPresent()) {
233                 updateState(channelUID, new StringType(sensiboSky.getAcState().get().getMode()));
234             } else {
235                 updateState(channelUID, UnDefType.UNDEF);
236             }
237         } else if (command instanceof StringType) {
238             final StringType newValue = (StringType) command;
239             updateAcState(sensiboSky, MODE_PROPERTY, newValue.toString());
240             addDynamicChannelsAndProperties(sensiboSky);
241         }
242     }
243
244     private void handleTargetTemperatureCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
245         if (command instanceof RefreshType) {
246             sensiboSky.getAcState().ifPresent(acState -> {
247                 @Nullable
248                 Integer targetTemperature = acState.getTargetTemperature();
249                 if (targetTemperature != null) {
250                     updateState(channelUID, new QuantityType<>(targetTemperature, sensiboSky.getTemperatureUnit()));
251                 } else {
252                     updateState(channelUID, UnDefType.UNDEF);
253                 }
254             });
255             if (!sensiboSky.getAcState().isPresent()) {
256                 updateState(channelUID, UnDefType.UNDEF);
257             }
258         } else if (command instanceof QuantityType<?>) {
259             QuantityType<?> newValue = (QuantityType<?>) command;
260             if (!Objects.equals(sensiboSky.getTemperatureUnit(), newValue.getUnit())) {
261                 // If quantity is given in celsius when fahrenheit is used or opposite
262                 try {
263                     UnitConverter temperatureConverter = newValue.getUnit()
264                             .getConverterToAny(sensiboSky.getTemperatureUnit());
265                     // No decimals supported
266                     long convertedValue = (long) temperatureConverter.convert(newValue.longValue());
267                     updateAcState(sensiboSky, TARGET_TEMPERATURE_PROPERTY, new DecimalType(convertedValue));
268                 } catch (UnconvertibleException | IncommensurableException e) {
269                     logger.info("Could not convert {} to {}: {}", newValue, sensiboSky.getTemperatureUnit(),
270                             e.getMessage());
271                 }
272             } else {
273                 updateAcState(sensiboSky, TARGET_TEMPERATURE_PROPERTY, new DecimalType(newValue.intValue()));
274             }
275         } else if (command instanceof DecimalType) {
276             updateAcState(sensiboSky, TARGET_TEMPERATURE_PROPERTY, command);
277         }
278     }
279
280     private void handleMasterSwitchCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
281         if (command instanceof RefreshType) {
282             sensiboSky.getAcState().ifPresent(e -> updateState(channelUID, OnOffType.from(e.isOn())));
283         } else if (command instanceof OnOffType) {
284             updateAcState(sensiboSky, MASTER_SWITCH_PROPERTY, command == OnOffType.ON);
285         }
286     }
287
288     private void handleCurrentTemperatureCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
289         if (command instanceof RefreshType) {
290             updateState(channelUID, new QuantityType<>(sensiboSky.getTemperature(), SIUnits.CELSIUS));
291         }
292     }
293
294     private void handleCurrentHumidityCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
295         if (command instanceof RefreshType) {
296             updateState(channelUID, new QuantityType<>(sensiboSky.getHumidity(), Units.PERCENT));
297         }
298     }
299
300     @Override
301     public Collection<Class<? extends ThingHandlerService>> getServices() {
302         return Collections.singleton(CallbackChannelsTypeProvider.class);
303     }
304
305     @Override
306     public void initialize() {
307         config = Optional.ofNullable(getConfigAs(SensiboSkyConfiguration.class));
308         logger.debug("Initializing SensiboSky using config {}", config);
309         getSensiboModel().findSensiboSkyByMacAddress(getMacAddress()).ifPresent(pod -> {
310
311             if (pod.isAlive()) {
312                 addDynamicChannelsAndProperties(pod);
313                 updateStatus(ThingStatus.ONLINE);
314             } else {
315                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
316                         "Unreachable by Sensibo servers");
317             }
318         });
319     }
320
321     private boolean isDynamicChannel(final ChannelTypeUID uid) {
322         return SensiboBindingConstants.DYNAMIC_CHANNEL_TYPES.stream().anyMatch(e -> uid.getId().startsWith(e));
323     }
324
325     private void addDynamicChannelsAndProperties(final SensiboSky sensiboSky) {
326         logger.debug("Updating dynamic channels for {}", sensiboSky.getId());
327         final List<Channel> newChannels = new ArrayList<>();
328         for (final Channel channel : getThing().getChannels()) {
329             final ChannelTypeUID channelTypeUID = channel.getChannelTypeUID();
330             if (channelTypeUID != null && !isDynamicChannel(channelTypeUID)) {
331                 newChannels.add(channel);
332             }
333         }
334
335         newChannels.addAll(createDynamicChannels(sensiboSky));
336         Map<String, String> properties = sensiboSky.getThingProperties();
337         updateThing(editThing().withChannels(newChannels).withProperties(properties).build());
338     }
339
340     public List<Channel> createDynamicChannels(final SensiboSky sensiboSky) {
341         final List<Channel> newChannels = new ArrayList<>();
342         generatedChannelTypes.clear();
343
344         sensiboSky.getCurrentModeCapabilities().ifPresent(capabilities -> {
345             // Not all modes have swing and fan level
346             final ChannelTypeUID swingModeChannelType = addChannelType(SensiboBindingConstants.CHANNEL_TYPE_SWING_MODE,
347                     SWING_MODE_LABEL, ITEM_TYPE_STRING, capabilities.swingModes, null, null);
348             newChannels
349                     .add(ChannelBuilder
350                             .create(new ChannelUID(getThing().getUID(), SensiboBindingConstants.CHANNEL_SWING_MODE),
351                                     ITEM_TYPE_STRING)
352                             .withLabel(SWING_MODE_LABEL).withType(swingModeChannelType).build());
353
354             final ChannelTypeUID fanLevelChannelType = addChannelType(SensiboBindingConstants.CHANNEL_TYPE_FAN_LEVEL,
355                     FAN_LEVEL_LABEL, ITEM_TYPE_STRING, capabilities.fanLevels, null, null);
356             newChannels.add(ChannelBuilder
357                     .create(new ChannelUID(getThing().getUID(), SensiboBindingConstants.CHANNEL_FAN_LEVEL),
358                             ITEM_TYPE_STRING)
359                     .withLabel(FAN_LEVEL_LABEL).withType(fanLevelChannelType).build());
360         });
361
362         final ChannelTypeUID modeChannelType = addChannelType(SensiboBindingConstants.CHANNEL_TYPE_MODE, MODE_LABEL,
363                 ITEM_TYPE_STRING, sensiboSky.getRemoteCapabilities().keySet(), null, null);
364         newChannels.add(ChannelBuilder
365                 .create(new ChannelUID(getThing().getUID(), SensiboBindingConstants.CHANNEL_MODE), ITEM_TYPE_STRING)
366                 .withLabel(MODE_LABEL).withType(modeChannelType).build());
367
368         final ChannelTypeUID targetTemperatureChannelType = addChannelType(
369                 SensiboBindingConstants.CHANNEL_TYPE_TARGET_TEMPERATURE, TARGET_TEMPERATURE_LABEL,
370                 ITEM_TYPE_NUMBER_TEMPERATURE, sensiboSky.getTargetTemperatures(), "%d %unit%",
371                 Set.of("Setpoint", "Temperature"));
372         newChannels.add(ChannelBuilder
373                 .create(new ChannelUID(getThing().getUID(), SensiboBindingConstants.CHANNEL_TARGET_TEMPERATURE),
374                         ITEM_TYPE_NUMBER_TEMPERATURE)
375                 .withLabel(TARGET_TEMPERATURE_LABEL).withType(targetTemperatureChannelType).build());
376
377         return newChannels;
378     }
379
380     private ChannelTypeUID addChannelType(final String channelTypePrefix, final String label, final String itemType,
381             final Collection<?> options, @Nullable final String pattern, @Nullable final Set<String> tags) {
382         final ChannelTypeUID channelTypeUID = new ChannelTypeUID(SensiboBindingConstants.BINDING_ID,
383                 channelTypePrefix + getThing().getUID().getId());
384         final List<StateOption> stateOptions = options.stream()
385                 .map(e -> new StateOption(e.toString(), e instanceof String ? beautify((String) e) : e.toString()))
386                 .collect(Collectors.toList());
387
388         StateDescriptionFragmentBuilder stateDescription = StateDescriptionFragmentBuilder.create().withReadOnly(false)
389                 .withOptions(stateOptions);
390         if (pattern != null) {
391             stateDescription = stateDescription.withPattern(pattern);
392         }
393         final StateChannelTypeBuilder builder = ChannelTypeBuilder.state(channelTypeUID, label, itemType)
394                 .withStateDescriptionFragment(stateDescription.build());
395         if (tags != null && !tags.isEmpty()) {
396             builder.withTags(tags);
397         }
398         final ChannelType channelType = builder.build();
399
400         generatedChannelTypes.put(channelTypeUID, channelType);
401
402         return channelTypeUID;
403     }
404
405     @Override
406     public Collection<ChannelType> getChannelTypes(@Nullable final Locale locale) {
407         return generatedChannelTypes.values();
408     }
409
410     @Override
411     public @Nullable ChannelType getChannelType(final ChannelTypeUID channelTypeUID, @Nullable final Locale locale) {
412         return generatedChannelTypes.get(channelTypeUID);
413     }
414
415     /*
416      * Package private in order to be reachable from unit test
417      */
418     StateChange checkStateChangeValid(SensiboSky sensiboSky, String property, Object newPropertyValue) {
419         StateChange stateChange = new StateChange(newPropertyValue);
420
421         sensiboSky.getCurrentModeCapabilities().ifPresent(currentModeCapabilities -> {
422             switch (property) {
423                 case TARGET_TEMPERATURE_PROPERTY:
424                     Unit<Temperature> temperatureUnit = sensiboSky.getTemperatureUnit();
425                     TemperatureDTO validTemperatures = currentModeCapabilities.temperatures
426                             .get(SIUnits.CELSIUS.equals(temperatureUnit) ? "C" : "F");
427                     DecimalType rawValue = (DecimalType) newPropertyValue;
428                     stateChange.updateValue(rawValue.intValue());
429                     if (!validTemperatures.validValues.contains(rawValue.intValue())) {
430                         stateChange.addError(String.format(
431                                 "Cannot change targetTemperature to '%d', valid targetTemperatures are one of %s",
432                                 rawValue.intValue(), ToStringBuilder.reflectionToString(
433                                         validTemperatures.validValues.toArray(), ToStringStyle.SIMPLE_STYLE)));
434                     }
435                     break;
436                 case MODE_PROPERTY:
437                     if (!sensiboSky.getRemoteCapabilities().containsKey(newPropertyValue)) {
438                         stateChange.addError(
439                                 String.format("Cannot change mode to %s, valid modes are %s", newPropertyValue,
440                                         ToStringBuilder.reflectionToString(
441                                                 sensiboSky.getRemoteCapabilities().keySet().toArray(),
442                                                 ToStringStyle.SIMPLE_STYLE)));
443                     }
444                     break;
445                 case FAN_LEVEL_PROPERTY:
446                     if (!currentModeCapabilities.fanLevels.contains(newPropertyValue)) {
447                         stateChange.addError(String.format("Cannot change fanLevel to %s, valid fanLevels are %s",
448                                 newPropertyValue, ToStringBuilder.reflectionToString(
449                                         currentModeCapabilities.fanLevels.toArray(), ToStringStyle.SIMPLE_STYLE)));
450                     }
451                     break;
452                 case MASTER_SWITCH_PROPERTY:
453                     // Always allowed
454                     break;
455                 case SWING_PROPERTY:
456                     if (!currentModeCapabilities.swingModes.contains(newPropertyValue)) {
457                         stateChange.addError(String.format("Cannot change swing to %s, valid swings are %s",
458                                 newPropertyValue, ToStringBuilder.reflectionToString(
459                                         currentModeCapabilities.swingModes.toArray(), ToStringStyle.SIMPLE_STYLE)));
460                     }
461                     break;
462                 default:
463                     stateChange.addError(String.format("No such ac state property %s", property));
464             }
465             logger.debug("State change request {}", stateChange);
466         });
467         return stateChange;
468     }
469
470     @NonNullByDefault
471     public class StateChange {
472         Object value;
473
474         boolean valid = true;
475         @Nullable
476         String validationMessage;
477
478         public StateChange(Object value) {
479             this.value = value;
480         }
481
482         public void updateValue(Object updatedValue) {
483             value = updatedValue;
484         }
485
486         public void addError(String validationMessage) {
487             valid = false;
488             this.validationMessage = validationMessage;
489         }
490
491         @Override
492         public String toString() {
493             return "StateChange [valid=" + valid + ", validationMessage=" + validationMessage + ", value=" + value
494                     + ", value Class=" + value.getClass() + "]";
495         }
496     }
497 }