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