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