2 * Copyright (c) 2010-2023 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.*;
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;
24 import java.util.Objects;
25 import java.util.Optional;
27 import java.util.stream.Collectors;
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;
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;
71 * The {@link SensiboSkyHandler} is responsible for handling commands, which are
72 * sent to one of the channels.
74 * @author Arne Seime - Initial contribution
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();
93 public SensiboSkyHandler(final Thing thing) {
97 private static String beautify(final String camelCaseWording) {
98 final StringBuilder b = new StringBuilder();
99 for (final String s : StringUtils.splitByCharacterType(camelCaseWording)) {
103 final StringBuilder bs = new StringBuilder();
104 for (final String t : b.toString().split("[ ][_]")) {
109 return StringUtils.capitalizeFully(bs.toString()).trim();
112 private String getMacAddress() {
113 if (config.isPresent()) {
114 return config.get().macAddress;
116 throw new IllegalArgumentException("No configuration present");
120 public void handleCommand(final ChannelUID channelUID, final Command command) {
121 handleCommand(channelUID, command, getSensiboModel());
125 * Package private in order to be reachable from unit test
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));
133 logger.info("Update command not sent; invalid state change for SensiboSky AC state: {}",
134 stateChange.validationMessage);
138 private void updateTimer(@Nullable Integer secondsFromNowUntilSwitchOff) {
140 .ifPresent(handler -> handler.updateSensiboSkyTimer(getMacAddress(), secondsFromNowUntilSwitchOff));
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
151 switch (channelUID.getId()) {
152 case CHANNEL_CURRENT_HUMIDITY:
153 handleCurrentHumidityCommand(channelUID, command, sensiboSky);
155 case CHANNEL_CURRENT_TEMPERATURE:
156 handleCurrentTemperatureCommand(channelUID, command, sensiboSky);
158 case CHANNEL_MASTER_SWITCH:
159 handleMasterSwitchCommand(channelUID, command, sensiboSky);
161 case CHANNEL_TARGET_TEMPERATURE:
162 handleTargetTemperatureCommand(channelUID, command, sensiboSky);
165 handleModeCommand(channelUID, command, sensiboSky);
167 case CHANNEL_SWING_MODE:
168 handleSwingCommand(channelUID, command, sensiboSky);
170 case CHANNEL_FAN_LEVEL:
171 handleFanLevelCommand(channelUID, command, sensiboSky);
174 handleTimerCommand(channelUID, command, sensiboSky);
177 logger.debug("Received command on unknown channel {}, ignoring", channelUID.getId());
180 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
181 "Unreachable by Sensibo servers");
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));
191 updateState(channelUID, UnDefType.UNDEF);
193 } else if (command instanceof DecimalType) {
194 final DecimalType newValue = (DecimalType) command;
195 updateTimer(newValue.intValue());
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()));
206 updateState(channelUID, UnDefType.UNDEF);
208 } else if (command instanceof StringType) {
209 final StringType newValue = (StringType) command;
210 updateAcState(sensiboSky, FAN_LEVEL_PROPERTY, newValue.toString());
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()));
219 updateState(channelUID, UnDefType.UNDEF);
221 } else if (command instanceof StringType) {
222 final StringType newValue = (StringType) command;
223 updateAcState(sensiboSky, SWING_PROPERTY, newValue.toString());
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()));
232 updateState(channelUID, UnDefType.UNDEF);
234 } else if (command instanceof StringType) {
235 final StringType newValue = (StringType) command;
236 updateAcState(sensiboSky, MODE_PROPERTY, newValue.toString());
237 addDynamicChannelsAndProperties(sensiboSky);
241 private void handleTargetTemperatureCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
242 if (command instanceof RefreshType) {
243 sensiboSky.getAcState().ifPresent(acState -> {
245 Integer targetTemperature = acState.getTargetTemperature();
246 if (targetTemperature != null) {
247 updateState(channelUID, new QuantityType<>(targetTemperature, sensiboSky.getTemperatureUnit()));
249 updateState(channelUID, UnDefType.UNDEF);
252 if (!sensiboSky.getAcState().isPresent()) {
253 updateState(channelUID, UnDefType.UNDEF);
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
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(),
270 updateAcState(sensiboSky, TARGET_TEMPERATURE_PROPERTY, new DecimalType(newValue.intValue()));
272 } else if (command instanceof DecimalType) {
273 updateAcState(sensiboSky, TARGET_TEMPERATURE_PROPERTY, command);
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);
285 private void handleCurrentTemperatureCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
286 if (command instanceof RefreshType) {
287 updateState(channelUID, new QuantityType<>(sensiboSky.getTemperature(), SIUnits.CELSIUS));
291 private void handleCurrentHumidityCommand(ChannelUID channelUID, Command command, SensiboSky sensiboSky) {
292 if (command instanceof RefreshType) {
293 updateState(channelUID, new QuantityType<>(sensiboSky.getHumidity(), Units.PERCENT));
298 public Collection<Class<? extends ThingHandlerService>> getServices() {
299 return Collections.singleton(CallbackChannelsTypeProvider.class);
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 -> {
309 addDynamicChannelsAndProperties(pod);
310 updateStatus(ThingStatus.ONLINE);
312 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
313 "Unreachable by Sensibo servers");
318 private boolean isDynamicChannel(final ChannelTypeUID uid) {
319 return SensiboBindingConstants.DYNAMIC_CHANNEL_TYPES.stream().anyMatch(e -> uid.getId().startsWith(e));
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);
332 newChannels.addAll(createDynamicChannels(sensiboSky));
333 Map<String, String> properties = sensiboSky.getThingProperties();
334 updateThing(editThing().withChannels(newChannels).withProperties(properties).build());
337 public List<Channel> createDynamicChannels(final SensiboSky sensiboSky) {
338 final List<Channel> newChannels = new ArrayList<>();
339 generatedChannelTypes.clear();
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);
347 .create(new ChannelUID(getThing().getUID(), SensiboBindingConstants.CHANNEL_SWING_MODE),
349 .withLabel(SWING_MODE_LABEL).withType(swingModeChannelType).build());
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),
356 .withLabel(FAN_LEVEL_LABEL).withType(fanLevelChannelType).build());
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());
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());
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());
385 StateDescriptionFragmentBuilder stateDescription = StateDescriptionFragmentBuilder.create().withReadOnly(false)
386 .withOptions(stateOptions);
387 if (pattern != null) {
388 stateDescription = stateDescription.withPattern(pattern);
390 final StateChannelTypeBuilder builder = ChannelTypeBuilder.state(channelTypeUID, label, itemType)
391 .withStateDescriptionFragment(stateDescription.build());
392 if (tags != null && !tags.isEmpty()) {
393 builder.withTags(tags);
395 final ChannelType channelType = builder.build();
397 generatedChannelTypes.put(channelTypeUID, channelType);
399 return channelTypeUID;
403 public Collection<ChannelType> getChannelTypes(@Nullable final Locale locale) {
404 return generatedChannelTypes.values();
408 public @Nullable ChannelType getChannelType(final ChannelTypeUID channelTypeUID, @Nullable final Locale locale) {
409 return generatedChannelTypes.get(channelTypeUID);
413 * Package private in order to be reachable from unit test
415 StateChange checkStateChangeValid(SensiboSky sensiboSky, String property, Object newPropertyValue) {
416 StateChange stateChange = new StateChange(newPropertyValue);
418 sensiboSky.getCurrentModeCapabilities().ifPresent(currentModeCapabilities -> {
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",
430 String.join(",", validTemperatures.validValues.stream().map(Object::toString)
431 .collect(Collectors.toUnmodifiableList()).toArray(new String[0]))));
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())));
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]))));
447 case MASTER_SWITCH_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]))));
458 stateChange.addError(String.format("No such ac state property %s", property));
460 logger.debug("State change request {}", stateChange);
466 public class StateChange {
469 boolean valid = true;
471 String validationMessage;
473 public StateChange(Object value) {
477 public void updateValue(Object updatedValue) {
478 value = updatedValue;
481 public void addError(String validationMessage) {
483 this.validationMessage = validationMessage;
487 public String toString() {
488 return "StateChange [valid=" + valid + ", validationMessage=" + validationMessage + ", value=" + value
489 + ", value Class=" + value.getClass() + "]";