2 * Copyright (c) 2010-2020 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.sensibo.internal.handler;
15 import static org.openhab.binding.sensibo.internal.SensiboBindingConstants.CHANNEL_CURRENT_HUMIDITY;
16 import static org.openhab.binding.sensibo.internal.SensiboBindingConstants.CHANNEL_CURRENT_TEMPERATURE;
17 import static org.openhab.binding.sensibo.internal.SensiboBindingConstants.CHANNEL_FAN_LEVEL;
18 import static org.openhab.binding.sensibo.internal.SensiboBindingConstants.CHANNEL_MASTER_SWITCH;
19 import static org.openhab.binding.sensibo.internal.SensiboBindingConstants.CHANNEL_MODE;
20 import static org.openhab.binding.sensibo.internal.SensiboBindingConstants.CHANNEL_SWING_MODE;
21 import static org.openhab.binding.sensibo.internal.SensiboBindingConstants.CHANNEL_TARGET_TEMPERATURE;
22 import static org.openhab.binding.sensibo.internal.SensiboBindingConstants.CHANNEL_TIMER;
24 import java.util.ArrayList;
25 import java.util.Collection;
26 import java.util.Collections;
27 import java.util.HashMap;
28 import java.util.List;
29 import java.util.Locale;
31 import java.util.Objects;
32 import java.util.Optional;
33 import java.util.stream.Collectors;
35 import javax.measure.IncommensurableException;
36 import javax.measure.UnconvertibleException;
37 import javax.measure.Unit;
38 import javax.measure.UnitConverter;
39 import javax.measure.quantity.Temperature;
41 import org.apache.commons.lang.StringUtils;
42 import org.apache.commons.lang.WordUtils;
43 import org.apache.commons.lang.builder.ToStringBuilder;
44 import org.apache.commons.lang.builder.ToStringStyle;
45 import org.eclipse.jdt.annotation.NonNullByDefault;
46 import org.eclipse.jdt.annotation.Nullable;
47 import org.openhab.binding.sensibo.internal.CallbackChannelsTypeProvider;
48 import org.openhab.binding.sensibo.internal.SensiboBindingConstants;
49 import org.openhab.binding.sensibo.internal.config.SensiboSkyConfiguration;
50 import org.openhab.binding.sensibo.internal.dto.poddetails.TemperatureDTO;
51 import org.openhab.binding.sensibo.internal.model.SensiboModel;
52 import org.openhab.binding.sensibo.internal.model.SensiboSky;
53 import org.openhab.core.library.types.DecimalType;
54 import org.openhab.core.library.types.OnOffType;
55 import org.openhab.core.library.types.QuantityType;
56 import org.openhab.core.library.types.StringType;
57 import org.openhab.core.library.unit.SIUnits;
58 import org.openhab.core.thing.Channel;
59 import org.openhab.core.thing.ChannelUID;
60 import org.openhab.core.thing.Thing;
61 import org.openhab.core.thing.ThingStatus;
62 import org.openhab.core.thing.ThingStatusDetail;
63 import org.openhab.core.thing.binding.ThingHandlerService;
64 import org.openhab.core.thing.binding.builder.ChannelBuilder;
65 import org.openhab.core.thing.type.ChannelType;
66 import org.openhab.core.thing.type.ChannelTypeBuilder;
67 import org.openhab.core.thing.type.ChannelTypeProvider;
68 import org.openhab.core.thing.type.ChannelTypeUID;
69 import org.openhab.core.thing.type.StateChannelTypeBuilder;
70 import org.openhab.core.types.Command;
71 import org.openhab.core.types.RefreshType;
72 import org.openhab.core.types.StateDescriptionFragmentBuilder;
73 import org.openhab.core.types.StateOption;
74 import org.openhab.core.types.UnDefType;
75 import org.slf4j.Logger;
76 import org.slf4j.LoggerFactory;
78 import tec.uom.se.unit.Units;
81 * The {@link SensiboSkyHandler} is responsible for handling commands, which are
82 * sent to one of the channels.
84 * @author Arne Seime - Initial contribution
87 public class SensiboSkyHandler extends SensiboBaseThingHandler implements ChannelTypeProvider {
88 public static final String SWING_PROPERTY = "swing";
89 public static final String MASTER_SWITCH_PROPERTY = "on";
90 public static final String FAN_LEVEL_PROPERTY = "fanLevel";
91 public static final String MODE_PROPERTY = "mode";
92 public static final String TARGET_TEMPERATURE_PROPERTY = "targetTemperature";
93 public static final String SWING_MODE_LABEL = "Swing Mode";
94 public static final String FAN_LEVEL_LABEL = "Fan Level";
95 public static final String MODE_LABEL = "Mode";
96 public static final String TARGET_TEMPERATURE_LABEL = "Target Temperature";
97 private static final String ITEM_TYPE_STRING = "String";
98 private static final String ITEM_TYPE_NUMBER_TEMPERATURE = "Number:Temperature";
99 private final Logger logger = LoggerFactory.getLogger(SensiboSkyHandler.class);
100 private final Map<ChannelTypeUID, ChannelType> generatedChannelTypes = new HashMap<>();
101 private Optional<SensiboSkyConfiguration> config = Optional.empty();
103 public SensiboSkyHandler(final Thing thing) {
107 private static String beautify(final String camelCaseWording) {
108 final StringBuilder b = new StringBuilder();
109 for (final String s : StringUtils.splitByCharacterTypeCamelCase(camelCaseWording)) {
113 final StringBuilder bs = new StringBuilder();
114 for (final String t : StringUtils.splitByWholeSeparator(b.toString(), " _")) {
119 return WordUtils.capitalizeFully(bs.toString()).trim();
122 private String getMacAddress() {
123 if (config.isPresent()) {
124 return config.get().macAddress;
126 throw new IllegalArgumentException("No configuration present");
130 public void handleCommand(final ChannelUID channelUID, final Command command) {
131 handleCommand(channelUID, command, getSensiboModel());
135 * Package private in order to be reachable from unit test
137 void updateAcState(SensiboSky sensiboSky, String property, Object value) {
138 StateChange stateChange = checkStateChangeValid(sensiboSky, property, value);
139 if (stateChange.valid) {
140 getAccountHandler().ifPresent(
141 handler -> handler.updateSensiboSkyAcState(getMacAddress(), property, stateChange.value, this));
143 logger.info("Update command not sent; invalid state change for SensiboSky AC state: {}",
144 stateChange.validationMessage);
148 private void updateTimer(@Nullable Integer secondsFromNowUntilSwitchOff) {
150 .ifPresent(handler -> handler.updateSensiboSkyTimer(getMacAddress(), secondsFromNowUntilSwitchOff));
154 protected void handleCommand(final ChannelUID channelUID, final Command command, final SensiboModel model) {
155 model.findSensiboSkyByMacAddress(getMacAddress()).ifPresent(sensiboSky -> {
156 if (sensiboSky.isAlive()) {
157 if (getThing().getStatus() != ThingStatus.ONLINE) {
158 addDynamicChannelsAndProperties(sensiboSky);
159 updateStatus(ThingStatus.ONLINE); // In case it has been offline
161 switch (channelUID.getId()) {
162 case CHANNEL_CURRENT_HUMIDITY:
163 handleCurrentHumidityCommand(channelUID, command, sensiboSky);
165 case CHANNEL_CURRENT_TEMPERATURE:
166 handleCurrentTemperatureCommand(channelUID, command, sensiboSky);
168 case CHANNEL_MASTER_SWITCH:
169 handleMasterSwitchCommand(channelUID, command, sensiboSky);
171 case CHANNEL_TARGET_TEMPERATURE:
172 handleTargetTemperatureCommand(channelUID, command, sensiboSky);
175 handleModeCommand(channelUID, command, sensiboSky);
177 case CHANNEL_SWING_MODE:
178 handleSwingCommand(channelUID, command, sensiboSky);
180 case CHANNEL_FAN_LEVEL:
181 handleFanLevelCommand(channelUID, command, sensiboSky);
184 handleTimerCommand(channelUID, command, sensiboSky);
187 logger.debug("Received command on unknown channel {}, ignoring", channelUID.getId());
190 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
191 "Unreachable by Sensibo servers");
196 private void handleTimerCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
197 if (command instanceof RefreshType) {
198 if (sensiboSky.getTimer().isPresent() && sensiboSky.getTimer().get().secondsRemaining > 0) {
199 updateState(channelUID, new DecimalType(sensiboSky.getTimer().get().secondsRemaining));
201 updateState(channelUID, UnDefType.UNDEF);
203 } else if (command instanceof DecimalType) {
204 final DecimalType newValue = (DecimalType) command;
205 updateTimer(newValue.intValue());
211 private void handleFanLevelCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
212 if (command instanceof RefreshType) {
213 if (sensiboSky.getAcState().isPresent() && sensiboSky.getAcState().get().getFanLevel() != null) {
214 updateState(channelUID, new StringType(sensiboSky.getAcState().get().getFanLevel()));
216 updateState(channelUID, UnDefType.UNDEF);
218 } else if (command instanceof StringType) {
219 final StringType newValue = (StringType) command;
220 updateAcState(sensiboSky, FAN_LEVEL_PROPERTY, newValue.toString());
224 private void handleSwingCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
225 if (command instanceof RefreshType && sensiboSky.getAcState().isPresent()) {
226 if (sensiboSky.getAcState().isPresent() && sensiboSky.getAcState().get().getSwing() != null) {
227 updateState(channelUID, new StringType(sensiboSky.getAcState().get().getSwing()));
229 updateState(channelUID, UnDefType.UNDEF);
231 } else if (command instanceof StringType) {
232 final StringType newValue = (StringType) command;
233 updateAcState(sensiboSky, SWING_PROPERTY, newValue.toString());
237 private void handleModeCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
238 if (command instanceof RefreshType) {
239 if (sensiboSky.getAcState().isPresent()) {
240 updateState(channelUID, new StringType(sensiboSky.getAcState().get().getMode()));
242 updateState(channelUID, UnDefType.UNDEF);
244 } else if (command instanceof StringType) {
245 final StringType newValue = (StringType) command;
246 updateAcState(sensiboSky, MODE_PROPERTY, newValue.toString());
247 addDynamicChannelsAndProperties(sensiboSky);
251 private void handleTargetTemperatureCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
252 if (command instanceof RefreshType) {
253 sensiboSky.getAcState().ifPresent(acState -> {
255 Integer targetTemperature = acState.getTargetTemperature();
256 if (targetTemperature != null) {
257 updateState(channelUID, new QuantityType<>(targetTemperature, sensiboSky.getTemperatureUnit()));
259 updateState(channelUID, UnDefType.UNDEF);
262 if (!sensiboSky.getAcState().isPresent()) {
263 updateState(channelUID, UnDefType.UNDEF);
265 } else if (command instanceof QuantityType<?>) {
266 QuantityType<?> newValue = (QuantityType<?>) command;
267 if (!Objects.equals(sensiboSky.getTemperatureUnit(), newValue.getUnit())) {
268 // If quantity is given in celsius when fahrenheit is used or opposite
270 UnitConverter temperatureConverter = newValue.getUnit()
271 .getConverterToAny(sensiboSky.getTemperatureUnit());
272 // No decimals supported
273 long convertedValue = (long) temperatureConverter.convert(newValue.longValue());
274 updateAcState(sensiboSky, TARGET_TEMPERATURE_PROPERTY, new DecimalType(convertedValue));
275 } catch (UnconvertibleException | IncommensurableException e) {
276 logger.info("Could not convert {} to {}: {}", newValue, sensiboSky.getTemperatureUnit(),
280 updateAcState(sensiboSky, TARGET_TEMPERATURE_PROPERTY, new DecimalType(newValue.intValue()));
282 } else if (command instanceof DecimalType) {
283 updateAcState(sensiboSky, TARGET_TEMPERATURE_PROPERTY, command);
287 private void handleMasterSwitchCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
288 if (command instanceof RefreshType) {
289 sensiboSky.getAcState().ifPresent(e -> updateState(channelUID, OnOffType.from(e.isOn())));
290 } else if (command instanceof OnOffType) {
291 updateAcState(sensiboSky, MASTER_SWITCH_PROPERTY, command == OnOffType.ON);
295 private void handleCurrentTemperatureCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
296 if (command instanceof RefreshType) {
297 updateState(channelUID, new QuantityType<>(sensiboSky.getTemperature(), SIUnits.CELSIUS));
301 private void handleCurrentHumidityCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
302 if (command instanceof RefreshType) {
303 updateState(channelUID, new QuantityType<>(sensiboSky.getHumidity(), Units.PERCENT));
308 public Collection<Class<? extends ThingHandlerService>> getServices() {
309 return Collections.singleton(CallbackChannelsTypeProvider.class);
313 public void initialize() {
314 config = Optional.ofNullable(getConfigAs(SensiboSkyConfiguration.class));
315 logger.debug("Initializing SensiboSky using config {}", config);
316 getSensiboModel().findSensiboSkyByMacAddress(getMacAddress()).ifPresent(pod -> {
319 addDynamicChannelsAndProperties(pod);
320 updateStatus(ThingStatus.ONLINE);
322 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
323 "Unreachable by Sensibo servers");
328 private boolean isDynamicChannel(final ChannelTypeUID uid) {
329 return SensiboBindingConstants.DYNAMIC_CHANNEL_TYPES.stream().anyMatch(e -> uid.getId().startsWith(e));
332 private void addDynamicChannelsAndProperties(final SensiboSky sensiboSky) {
333 logger.debug("Updating dynamic channels for {}", sensiboSky.getId());
334 final List<Channel> newChannels = new ArrayList<>();
335 for (final Channel channel : getThing().getChannels()) {
336 final ChannelTypeUID channelTypeUID = channel.getChannelTypeUID();
337 if (channelTypeUID != null && !isDynamicChannel(channelTypeUID)) {
338 newChannels.add(channel);
342 newChannels.addAll(createDynamicChannels(sensiboSky));
343 Map<String, String> properties = sensiboSky.getThingProperties();
344 updateThing(editThing().withChannels(newChannels).withProperties(properties).build());
347 public List<Channel> createDynamicChannels(final SensiboSky sensiboSky) {
348 final List<Channel> newChannels = new ArrayList<>();
349 generatedChannelTypes.clear();
351 sensiboSky.getCurrentModeCapabilities().ifPresent(capabilities -> {
352 // Not all modes have swing and fan level
353 final ChannelTypeUID swingModeChannelType = addChannelType(SensiboBindingConstants.CHANNEL_TYPE_SWING_MODE,
354 SWING_MODE_LABEL, ITEM_TYPE_STRING, capabilities.swingModes, null, null);
357 .create(new ChannelUID(getThing().getUID(), SensiboBindingConstants.CHANNEL_SWING_MODE),
359 .withLabel(SWING_MODE_LABEL).withType(swingModeChannelType).build());
361 final ChannelTypeUID fanLevelChannelType = addChannelType(SensiboBindingConstants.CHANNEL_TYPE_FAN_LEVEL,
362 FAN_LEVEL_LABEL, ITEM_TYPE_STRING, capabilities.fanLevels, null, null);
363 newChannels.add(ChannelBuilder
364 .create(new ChannelUID(getThing().getUID(), SensiboBindingConstants.CHANNEL_FAN_LEVEL),
366 .withLabel(FAN_LEVEL_LABEL).withType(fanLevelChannelType).build());
369 final ChannelTypeUID modeChannelType = addChannelType(SensiboBindingConstants.CHANNEL_TYPE_MODE, MODE_LABEL,
370 ITEM_TYPE_STRING, sensiboSky.getRemoteCapabilities().keySet(), null, null);
371 newChannels.add(ChannelBuilder
372 .create(new ChannelUID(getThing().getUID(), SensiboBindingConstants.CHANNEL_MODE), ITEM_TYPE_STRING)
373 .withLabel(MODE_LABEL).withType(modeChannelType).build());
375 final ChannelTypeUID targetTemperatureChannelType = addChannelType(
376 SensiboBindingConstants.CHANNEL_TYPE_TARGET_TEMPERATURE, TARGET_TEMPERATURE_LABEL,
377 ITEM_TYPE_NUMBER_TEMPERATURE, sensiboSky.getTargetTemperatures(), "%d %unit%", "TargetTemperature");
378 newChannels.add(ChannelBuilder
379 .create(new ChannelUID(getThing().getUID(), SensiboBindingConstants.CHANNEL_TARGET_TEMPERATURE),
380 ITEM_TYPE_NUMBER_TEMPERATURE)
381 .withLabel(TARGET_TEMPERATURE_LABEL).withType(targetTemperatureChannelType).build());
386 private ChannelTypeUID addChannelType(final String channelTypePrefix, final String label, final String itemType,
387 final Collection<?> options, @Nullable final String pattern, @Nullable final String tag) {
388 final ChannelTypeUID channelTypeUID = new ChannelTypeUID(SensiboBindingConstants.BINDING_ID,
389 channelTypePrefix + getThing().getUID().getId());
390 final List<StateOption> stateOptions = options.stream()
391 .map(e -> new StateOption(e.toString(), e instanceof String ? beautify((String) e) : e.toString()))
392 .collect(Collectors.toList());
394 StateDescriptionFragmentBuilder stateDescription = StateDescriptionFragmentBuilder.create().withReadOnly(false)
395 .withOptions(stateOptions);
396 if (pattern != null) {
397 stateDescription = stateDescription.withPattern(pattern);
399 final StateChannelTypeBuilder builder = ChannelTypeBuilder.state(channelTypeUID, label, itemType)
400 .withStateDescription(stateDescription.build().toStateDescription());
402 builder.withTag(tag);
404 final ChannelType channelType = builder.build();
406 generatedChannelTypes.put(channelTypeUID, channelType);
408 return channelTypeUID;
412 public Collection<ChannelType> getChannelTypes(@Nullable final Locale locale) {
413 return generatedChannelTypes.values();
417 public @Nullable ChannelType getChannelType(final ChannelTypeUID channelTypeUID, @Nullable final Locale locale) {
418 return generatedChannelTypes.get(channelTypeUID);
422 * Package private in order to be reachable from unit test
424 StateChange checkStateChangeValid(SensiboSky sensiboSky, String property, Object newPropertyValue) {
425 StateChange stateChange = new StateChange(newPropertyValue);
427 sensiboSky.getCurrentModeCapabilities().ifPresent(currentModeCapabilities -> {
429 case TARGET_TEMPERATURE_PROPERTY:
430 Unit<Temperature> temperatureUnit = sensiboSky.getTemperatureUnit();
431 TemperatureDTO validTemperatures = currentModeCapabilities.temperatures
432 .get(temperatureUnit == SIUnits.CELSIUS ? "C" : "F");
433 DecimalType rawValue = (DecimalType) newPropertyValue;
434 stateChange.updateValue(rawValue.intValue());
435 if (!validTemperatures.validValues.contains(rawValue.intValue())) {
436 stateChange.addError(String.format(
437 "Cannot change targetTemperature to '%d', valid targetTemperatures are one of %s",
438 rawValue.intValue(), ToStringBuilder.reflectionToString(
439 validTemperatures.validValues.toArray(), ToStringStyle.SIMPLE_STYLE)));
443 if (!sensiboSky.getRemoteCapabilities().containsKey(newPropertyValue)) {
444 stateChange.addError(
445 String.format("Cannot change mode to %s, valid modes are %s", newPropertyValue,
446 ToStringBuilder.reflectionToString(
447 sensiboSky.getRemoteCapabilities().keySet().toArray(),
448 ToStringStyle.SIMPLE_STYLE)));
451 case FAN_LEVEL_PROPERTY:
452 if (!currentModeCapabilities.fanLevels.contains(newPropertyValue)) {
453 stateChange.addError(String.format("Cannot change fanLevel to %s, valid fanLevels are %s",
454 newPropertyValue, ToStringBuilder.reflectionToString(
455 currentModeCapabilities.fanLevels.toArray(), ToStringStyle.SIMPLE_STYLE)));
458 case MASTER_SWITCH_PROPERTY:
462 if (!currentModeCapabilities.swingModes.contains(newPropertyValue)) {
463 stateChange.addError(String.format("Cannot change swing to %s, valid swings are %s",
464 newPropertyValue, ToStringBuilder.reflectionToString(
465 currentModeCapabilities.swingModes.toArray(), ToStringStyle.SIMPLE_STYLE)));
469 stateChange.addError(String.format("No such ac state property %s", property));
471 logger.debug("State change request {}", stateChange);
477 public class StateChange {
480 boolean valid = true;
482 String validationMessage;
484 public StateChange(Object value) {
488 public void updateValue(Object updatedValue) {
489 value = updatedValue;
492 public void addError(String validationMessage) {
494 this.validationMessage = validationMessage;
498 public String toString() {
499 return "StateChange [valid=" + valid + ", validationMessage=" + validationMessage + ", value=" + value
500 + ", value Class=" + value.getClass() + "]";