2 * Copyright (c) 2010-2022 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.tado.internal.handler;
15 import static org.openhab.binding.tado.internal.api.TadoApiTypeUtils.terminationConditionTemplateToTerminationCondition;
17 import java.io.IOException;
18 import java.util.List;
19 import java.util.concurrent.ScheduledFuture;
20 import java.util.concurrent.TimeUnit;
21 import java.util.stream.Collectors;
23 import javax.measure.quantity.Temperature;
25 import org.eclipse.jdt.annotation.NonNullByDefault;
26 import org.eclipse.jdt.annotation.Nullable;
27 import org.openhab.binding.tado.internal.TadoBindingConstants;
28 import org.openhab.binding.tado.internal.TadoBindingConstants.FanLevel;
29 import org.openhab.binding.tado.internal.TadoBindingConstants.HorizontalSwing;
30 import org.openhab.binding.tado.internal.TadoBindingConstants.OperationMode;
31 import org.openhab.binding.tado.internal.TadoBindingConstants.TemperatureUnit;
32 import org.openhab.binding.tado.internal.TadoBindingConstants.VerticalSwing;
33 import org.openhab.binding.tado.internal.TadoBindingConstants.ZoneType;
34 import org.openhab.binding.tado.internal.TadoHvacChange;
35 import org.openhab.binding.tado.internal.adapter.TadoZoneStateAdapter;
36 import org.openhab.binding.tado.internal.api.ApiException;
37 import org.openhab.binding.tado.internal.api.GsonBuilderFactory;
38 import org.openhab.binding.tado.internal.api.TadoApiTypeUtils;
39 import org.openhab.binding.tado.internal.api.model.ACFanLevel;
40 import org.openhab.binding.tado.internal.api.model.ACHorizontalSwing;
41 import org.openhab.binding.tado.internal.api.model.ACVerticalSwing;
42 import org.openhab.binding.tado.internal.api.model.AcMode;
43 import org.openhab.binding.tado.internal.api.model.AcModeCapabilities;
44 import org.openhab.binding.tado.internal.api.model.CoolingZoneSetting;
45 import org.openhab.binding.tado.internal.api.model.GenericZoneCapabilities;
46 import org.openhab.binding.tado.internal.api.model.GenericZoneSetting;
47 import org.openhab.binding.tado.internal.api.model.Overlay;
48 import org.openhab.binding.tado.internal.api.model.OverlayTemplate;
49 import org.openhab.binding.tado.internal.api.model.OverlayTerminationCondition;
50 import org.openhab.binding.tado.internal.api.model.TadoSystemType;
51 import org.openhab.binding.tado.internal.api.model.Zone;
52 import org.openhab.binding.tado.internal.api.model.ZoneState;
53 import org.openhab.binding.tado.internal.config.TadoZoneConfig;
54 import org.openhab.core.library.types.DecimalType;
55 import org.openhab.core.library.types.OnOffType;
56 import org.openhab.core.library.types.QuantityType;
57 import org.openhab.core.library.types.StringType;
58 import org.openhab.core.library.unit.ImperialUnits;
59 import org.openhab.core.library.unit.SIUnits;
60 import org.openhab.core.thing.Bridge;
61 import org.openhab.core.thing.Channel;
62 import org.openhab.core.thing.ChannelUID;
63 import org.openhab.core.thing.Thing;
64 import org.openhab.core.thing.ThingStatus;
65 import org.openhab.core.thing.ThingStatusDetail;
66 import org.openhab.core.thing.ThingStatusInfo;
67 import org.openhab.core.types.Command;
68 import org.openhab.core.types.RefreshType;
69 import org.openhab.core.types.StateOption;
70 import org.slf4j.Logger;
71 import org.slf4j.LoggerFactory;
73 import com.google.gson.Gson;
76 * The {@link TadoZoneHandler} is responsible for handling commands of zones and update their state.
78 * @author Dennis Frommknecht - Initial contribution
79 * @author Andrew Fiddian-Green - Added Low Battery Alarm, A/C Power and Open Window channels
83 public class TadoZoneHandler extends BaseHomeThingHandler {
84 private Logger logger = LoggerFactory.getLogger(TadoZoneHandler.class);
86 private final TadoStateDescriptionProvider stateDescriptionProvider;
87 private TadoZoneConfig configuration;
89 private @Nullable ScheduledFuture<?> refreshTimer;
90 private @Nullable ScheduledFuture<?> scheduledHvacChange;
91 private @Nullable GenericZoneCapabilities capabilities;
92 private @Nullable TadoHvacChange pendingHvacChange;
94 private boolean disposing = false;
95 private @Nullable Gson gson;
97 public TadoZoneHandler(Thing thing, TadoStateDescriptionProvider stateDescriptionProvider) {
99 this.stateDescriptionProvider = stateDescriptionProvider;
100 configuration = getConfigAs(TadoZoneConfig.class);
103 public long getZoneId() {
104 return configuration.id;
107 public int getFallbackTimerDuration() {
108 return configuration.fallbackTimerDuration;
111 public ZoneType getZoneType() {
112 String zoneTypeStr = thing.getProperties().get(TadoBindingConstants.PROPERTY_ZONE_TYPE);
113 if (zoneTypeStr == null) {
114 throw new IllegalStateException("Zone type not initialized");
116 return ZoneType.valueOf(zoneTypeStr);
119 public OverlayTerminationCondition getDefaultTerminationCondition() throws IOException, ApiException {
120 OverlayTemplate overlayTemplate = getApi().showZoneDefaultOverlay(getHomeId(), getZoneId());
121 logApiTransaction(overlayTemplate, false);
122 return terminationConditionTemplateToTerminationCondition(overlayTemplate.getTerminationCondition());
125 public ZoneState getZoneState() throws IOException, ApiException {
126 ZoneState zoneState = getApi().showZoneState(getHomeId(), getZoneId());
127 logApiTransaction(zoneState, false);
131 public GenericZoneCapabilities getZoneCapabilities() {
132 GenericZoneCapabilities capabilities = this.capabilities;
133 if (capabilities == null) {
134 throw new IllegalStateException("Zone capabilities not initialized");
139 public TemperatureUnit getTemperatureUnit() {
140 return getHomeHandler().getTemperatureUnit();
143 public Overlay setOverlay(Overlay overlay) throws IOException, ApiException {
145 logApiTransaction(overlay, true);
146 Overlay newOverlay = getApi().updateZoneOverlay(getHomeId(), getZoneId(), overlay);
147 logApiTransaction(newOverlay, false);
149 } catch (ApiException e) {
150 if (!logger.isTraceEnabled()) {
151 logger.warn("ApiException sending JSON content:\n{}", convertToJsonString(overlay));
157 public void removeOverlay() throws IOException, ApiException {
158 logger.debug("Removing overlay of home {} and zone {}", getHomeId(), getZoneId());
159 getApi().deleteZoneOverlay(getHomeId(), getZoneId());
163 public void handleCommand(ChannelUID channelUID, Command command) {
164 String id = channelUID.getId();
166 if (command == RefreshType.REFRESH) {
167 updateZoneState(false);
171 synchronized (this) {
172 TadoHvacChange pendingHvacChange = this.pendingHvacChange;
173 if (pendingHvacChange == null) {
174 throw new IllegalStateException("Zone pendingHvacChange not initialized");
178 case TadoBindingConstants.CHANNEL_ZONE_HVAC_MODE:
179 pendingHvacChange.withHvacMode(((StringType) command).toFullString());
180 scheduleHvacChange();
182 case TadoBindingConstants.CHANNEL_ZONE_TARGET_TEMPERATURE:
183 if (command instanceof QuantityType<?>) {
184 @SuppressWarnings("unchecked")
185 QuantityType<Temperature> state = (QuantityType<Temperature>) command;
186 QuantityType<Temperature> stateInTargetUnit = getTemperatureUnit() == TemperatureUnit.FAHRENHEIT
187 ? state.toUnit(ImperialUnits.FAHRENHEIT)
188 : state.toUnit(SIUnits.CELSIUS);
190 if (stateInTargetUnit != null) {
191 pendingHvacChange.withTemperature(stateInTargetUnit.floatValue());
192 scheduleHvacChange();
196 case TadoBindingConstants.CHANNEL_ZONE_SWING:
197 pendingHvacChange.withSwing(((OnOffType) command) == OnOffType.ON);
198 scheduleHvacChange();
200 case TadoBindingConstants.CHANNEL_ZONE_LIGHT:
201 pendingHvacChange.withLight(((OnOffType) command) == OnOffType.ON);
202 scheduleHvacChange();
204 case TadoBindingConstants.CHANNEL_ZONE_FAN_SPEED:
205 pendingHvacChange.withFanSpeed(((StringType) command).toFullString());
206 scheduleHvacChange();
208 case TadoBindingConstants.CHANNEL_ZONE_FAN_LEVEL:
209 String fanLevelString = ((StringType) command).toFullString();
210 pendingHvacChange.withFanLevel(FanLevel.valueOf(fanLevelString.toUpperCase()));
211 scheduleHvacChange();
213 case TadoBindingConstants.CHANNEL_ZONE_HORIZONTAL_SWING:
214 String horizontalSwingString = ((StringType) command).toFullString();
215 pendingHvacChange.withHorizontalSwing(HorizontalSwing.valueOf(horizontalSwingString.toUpperCase()));
216 scheduleHvacChange();
218 case TadoBindingConstants.CHANNEL_ZONE_VERTICAL_SWING:
219 String verticalSwingString = ((StringType) command).toFullString();
220 pendingHvacChange.withVerticalSwing(VerticalSwing.valueOf(verticalSwingString.toUpperCase()));
221 scheduleHvacChange();
223 case TadoBindingConstants.CHANNEL_ZONE_OPERATION_MODE:
224 String operationMode = ((StringType) command).toFullString();
225 pendingHvacChange.withOperationMode(OperationMode.valueOf(operationMode));
226 scheduleHvacChange();
228 case TadoBindingConstants.CHANNEL_ZONE_TIMER_DURATION:
229 pendingHvacChange.activeForMinutes(((DecimalType) command).intValue());
230 scheduleHvacChange();
237 public void initialize() {
239 configuration = getConfigAs(TadoZoneConfig.class);
240 if (configuration.refreshInterval <= 0) {
241 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Refresh interval of zone "
242 + getZoneId() + " of home " + getHomeId() + " must be greater than zero");
244 } else if (configuration.fallbackTimerDuration <= 0) {
245 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Fallback timer duration of zone "
246 + getZoneId() + " of home " + getHomeId() + " must be greater than zero");
248 } else if (configuration.hvacChangeDebounce <= 0) {
249 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "HVAC change debounce of zone "
250 + getZoneId() + " of home " + getHomeId() + " must be greater than zero");
254 Bridge bridge = getBridge();
255 if (bridge != null) {
256 bridgeStatusChanged(bridge.getStatusInfo());
261 public void dispose() {
263 cancelScheduledZoneStateUpdate();
267 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
268 if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE) {
270 Zone zoneDetails = getApi().showZoneDetails(getHomeId(), getZoneId());
271 logApiTransaction(zoneDetails, false);
273 GenericZoneCapabilities capabilities = getApi().showZoneCapabilities(getHomeId(), getZoneId());
274 logApiTransaction(capabilities, false);
276 if (zoneDetails == null || capabilities == null) {
277 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
278 "Can not access zone " + getZoneId() + " of home " + getHomeId());
282 updateProperty(TadoBindingConstants.PROPERTY_ZONE_NAME, zoneDetails.getName());
283 updateProperty(TadoBindingConstants.PROPERTY_ZONE_TYPE, zoneDetails.getType().name());
284 this.capabilities = capabilities;
285 } catch (IOException | ApiException e) {
286 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
287 "Could not connect to server due to " + e.getMessage());
288 cancelScheduledZoneStateUpdate();
292 scheduleZoneStateUpdate();
293 pendingHvacChange = new TadoHvacChange(getThing());
295 updateStatus(ThingStatus.ONLINE);
297 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
298 cancelScheduledZoneStateUpdate();
302 private void updateZoneState(boolean forceUpdate) {
303 if ((thing.getStatus() != ThingStatus.ONLINE) || disposing) {
307 getHomeHandler().updateHomeState();
309 // No update during HVAC change debounce
310 ScheduledFuture<?> scheduledHvacChange = this.scheduledHvacChange;
311 if (!forceUpdate && scheduledHvacChange != null && !scheduledHvacChange.isDone()) {
316 ZoneState zoneState = getZoneState();
318 logger.debug("Updating state of home {} and zone {}", getHomeId(), getZoneId());
320 TadoZoneStateAdapter state = new TadoZoneStateAdapter(zoneState, getTemperatureUnit());
321 updateState(TadoBindingConstants.CHANNEL_ZONE_CURRENT_TEMPERATURE, state.getInsideTemperature());
322 updateState(TadoBindingConstants.CHANNEL_ZONE_HUMIDITY, state.getHumidity());
324 updateState(TadoBindingConstants.CHANNEL_ZONE_HEATING_POWER, state.getHeatingPower());
325 updateState(TadoBindingConstants.CHANNEL_ZONE_AC_POWER, state.getAcPower());
327 updateState(TadoBindingConstants.CHANNEL_ZONE_OPERATION_MODE, state.getOperationMode());
329 updateState(TadoBindingConstants.CHANNEL_ZONE_HVAC_MODE, state.getMode());
330 updateState(TadoBindingConstants.CHANNEL_ZONE_TARGET_TEMPERATURE, state.getTargetTemperature());
331 updateState(TadoBindingConstants.CHANNEL_ZONE_FAN_SPEED, state.getFanSpeed());
332 updateState(TadoBindingConstants.CHANNEL_ZONE_SWING, state.getSwing());
333 updateState(TadoBindingConstants.CHANNEL_ZONE_LIGHT, state.getLight());
334 updateState(TadoBindingConstants.CHANNEL_ZONE_FAN_LEVEL, state.getFanLevel());
335 updateState(TadoBindingConstants.CHANNEL_ZONE_HORIZONTAL_SWING, state.getHorizontalSwing());
336 updateState(TadoBindingConstants.CHANNEL_ZONE_VERTICAL_SWING, state.getVerticalSwing());
338 updateState(TadoBindingConstants.CHANNEL_ZONE_TIMER_DURATION, state.getRemainingTimerDuration());
340 updateState(TadoBindingConstants.CHANNEL_ZONE_OVERLAY_EXPIRY, state.getOverlayExpiration());
342 updateState(TadoBindingConstants.CHANNEL_ZONE_OPEN_WINDOW_DETECTED, state.getOpenWindowDetected());
344 updateDynamicStateDescriptions(zoneState);
346 onSuccessfulOperation();
347 } catch (IOException | ApiException e) {
348 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
349 "Could not connect to server due to " + e.getMessage());
352 updateState(TadoBindingConstants.CHANNEL_ZONE_BATTERY_LOW_ALARM,
353 getHomeHandler().getBatteryLowAlarm(getZoneId()));
357 * Update the dynamic state descriptions for any channels which support an unknown sub- range of enumerator setting
358 * values, based on the list of capabilities reported by the respective zone.
360 * Note: currently this only applies to A/C devices that support fanLevel, horizontalSwing, or verticalSwing.
362 * @param zoneState the current zone Thing's state
364 private void updateDynamicStateDescriptions(ZoneState zoneState) {
365 GenericZoneSetting setting = zoneState.getSetting();
366 if (setting.getType() != TadoSystemType.AIR_CONDITIONING) {
370 AcMode acMode = ((CoolingZoneSetting) setting).getMode();
371 AcModeCapabilities acModeCapabilities = acMode == null ? new AcModeCapabilities()
372 : TadoApiTypeUtils.getModeCapabilities(acMode, capabilities);
374 // update the options list of supported fan levels
375 Channel channel = thing.getChannel(TadoBindingConstants.CHANNEL_ZONE_FAN_LEVEL);
376 if (channel != null) {
377 List<ACFanLevel> fanLevels = acModeCapabilities.getFanLevel();
378 if (fanLevels != null) {
379 stateDescriptionProvider.setStateOptions(channel.getUID(),
380 fanLevels.stream().map(u -> new StateOption(u.name(), u.name())).collect(Collectors.toList()));
384 // update the options list of supported horizontal swing settings
385 channel = thing.getChannel(TadoBindingConstants.CHANNEL_ZONE_HORIZONTAL_SWING);
386 if (channel != null) {
387 List<ACHorizontalSwing> horizontalSwings = acModeCapabilities.getHorizontalSwing();
388 if (horizontalSwings != null) {
389 stateDescriptionProvider.setStateOptions(channel.getUID(), horizontalSwings.stream()
390 .map(u -> new StateOption(u.name(), u.name())).collect(Collectors.toList()));
394 // update the options list of supported vertical swing settings
395 channel = thing.getChannel(TadoBindingConstants.CHANNEL_ZONE_VERTICAL_SWING);
396 if (channel != null) {
397 List<ACVerticalSwing> verticalSwings = acModeCapabilities.getVerticalSwing();
398 if (verticalSwings != null) {
399 stateDescriptionProvider.setStateOptions(channel.getUID(), verticalSwings.stream()
400 .map(u -> new StateOption(u.name(), u.name())).collect(Collectors.toList()));
405 private void scheduleZoneStateUpdate() {
406 ScheduledFuture<?> refreshTimer = this.refreshTimer;
407 if (refreshTimer == null || refreshTimer.isCancelled()) {
408 this.refreshTimer = scheduler.scheduleWithFixedDelay(new Runnable() {
411 updateZoneState(false);
413 }, 5, configuration.refreshInterval, TimeUnit.SECONDS);
417 private void cancelScheduledZoneStateUpdate() {
418 ScheduledFuture<?> refreshTimer = this.refreshTimer;
419 if (refreshTimer != null) {
420 refreshTimer.cancel(false);
424 private void scheduleHvacChange() {
425 ScheduledFuture<?> scheduledHvacChange = this.scheduledHvacChange;
426 if (scheduledHvacChange != null) {
427 scheduledHvacChange.cancel(false);
429 this.scheduledHvacChange = scheduler.schedule(() -> {
431 synchronized (this) {
432 TadoHvacChange pendingHvacChange = this.pendingHvacChange;
433 this.pendingHvacChange = new TadoHvacChange(getThing());
434 if (pendingHvacChange != null) {
435 pendingHvacChange.apply();
438 } catch (IOException e) {
439 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
440 } catch (ApiException e) {
441 logger.warn("Could not apply HVAC change on home {} and zone {}: {}", getHomeId(), getZoneId(),
444 updateZoneState(true);
446 }, configuration.hvacChangeDebounce, TimeUnit.SECONDS);
450 * Helper method to log an API transaction on the given object.
451 * If the logger level is 'debug', the transaction is simply logged.
452 * If the logger level is 'trace, the object's JSON serial contents are included.
454 * @param object the object to be logged.
455 * @param isCommand marks whether the transaction is a command to, or a response from, the server.
457 private void logApiTransaction(Object object, boolean isCommand) {
458 if (logger.isDebugEnabled() || logger.isTraceEnabled()) {
459 String logType = isCommand ? "command" : "response";
460 if (logger.isTraceEnabled()) {
461 logger.trace("Api {}: homeId:{}, zoneId:{}, objectId:{}, content:\n{}", logType, getHomeId(),
462 getZoneId(), object.getClass().getSimpleName(), convertToJsonString(object));
463 } else if (logger.isDebugEnabled()) {
464 logger.debug("Api {}: homeId:{}, zoneId:{}, objectId:{}", logType, getHomeId(), getZoneId(),
465 object.getClass().getSimpleName());
470 private synchronized String convertToJsonString(Object object) {
471 Gson gson = this.gson;
473 gson = this.gson = GsonBuilderFactory.defaultGsonBuilder().setPrettyPrinting().create();
475 return gson.toJson(object);