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.miele.internal.handler;
15 import static org.openhab.binding.miele.internal.MieleBindingConstants.*;
17 import java.time.Instant;
18 import java.time.ZoneId;
19 import java.time.ZonedDateTime;
20 import java.time.temporal.ChronoUnit;
21 import java.util.HashMap;
22 import java.util.IllformedLocaleException;
23 import java.util.Locale;
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.openhab.binding.miele.internal.DeviceUtil;
30 import org.openhab.binding.miele.internal.MieleTranslationProvider;
31 import org.openhab.binding.miele.internal.TimeStabilizer;
32 import org.openhab.binding.miele.internal.api.dto.DeviceClassObject;
33 import org.openhab.binding.miele.internal.api.dto.DeviceMetaData;
34 import org.openhab.binding.miele.internal.api.dto.DeviceProperty;
35 import org.openhab.binding.miele.internal.api.dto.HomeDevice;
36 import org.openhab.core.i18n.LocaleProvider;
37 import org.openhab.core.i18n.TimeZoneProvider;
38 import org.openhab.core.i18n.TranslationProvider;
39 import org.openhab.core.library.types.DateTimeType;
40 import org.openhab.core.library.types.OnOffType;
41 import org.openhab.core.thing.Bridge;
42 import org.openhab.core.thing.ChannelUID;
43 import org.openhab.core.thing.Thing;
44 import org.openhab.core.thing.ThingStatus;
45 import org.openhab.core.thing.ThingStatusDetail;
46 import org.openhab.core.thing.ThingTypeUID;
47 import org.openhab.core.thing.ThingUID;
48 import org.openhab.core.thing.binding.BaseThingHandler;
49 import org.openhab.core.thing.binding.ThingHandler;
50 import org.openhab.core.types.Command;
51 import org.openhab.core.types.RefreshType;
52 import org.openhab.core.types.State;
53 import org.openhab.core.types.UnDefType;
54 import org.slf4j.Logger;
55 import org.slf4j.LoggerFactory;
57 import com.google.gson.Gson;
58 import com.google.gson.JsonArray;
59 import com.google.gson.JsonElement;
60 import com.google.gson.JsonObject;
61 import com.google.gson.JsonParser;
64 * The {@link MieleApplianceHandler} is an abstract class
65 * responsible for handling commands, which are sent to one
66 * of the channels of the appliance that understands/"talks"
67 * the {@link ApplianceChannelSelector} datapoints
69 * @author Karel Goderis - Initial contribution
70 * @author Martin Lepsy - Added check for JsonNull result
71 * @author Jacob Laursen - Fixed multicast and protocol support (ZigBee/LAN)
74 public abstract class MieleApplianceHandler<E extends Enum<E> & ApplianceChannelSelector> extends BaseThingHandler
75 implements ApplianceStatusListener {
77 private final Logger logger = LoggerFactory.getLogger(MieleApplianceHandler.class);
79 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_DISHWASHER, THING_TYPE_OVEN,
80 THING_TYPE_FRIDGE, THING_TYPE_DRYER, THING_TYPE_HOB, THING_TYPE_FRIDGEFREEZER, THING_TYPE_HOOD,
81 THING_TYPE_WASHINGMACHINE, THING_TYPE_COFFEEMACHINE);
83 protected Gson gson = new Gson();
85 protected @Nullable String applianceId;
86 private @Nullable MieleBridgeHandler bridgeHandler;
87 protected TranslationProvider i18nProvider;
88 protected LocaleProvider localeProvider;
89 protected MieleTranslationProvider translationProvider;
90 private TimeZoneProvider timeZoneProvider;
91 private TimeStabilizer startTimeStabilizer;
92 private TimeStabilizer finishTimeStabilizer;
93 private Class<E> selectorType;
94 protected String modelID;
96 protected Map<String, String> metaDataCache = new HashMap<>();
98 public MieleApplianceHandler(Thing thing, TranslationProvider i18nProvider, LocaleProvider localeProvider,
99 TimeZoneProvider timeZoneProvider, Class<E> selectorType, String modelID) {
101 this.i18nProvider = i18nProvider;
102 this.localeProvider = localeProvider;
103 this.selectorType = selectorType;
104 this.modelID = modelID;
105 this.translationProvider = new MieleTranslationProvider(i18nProvider, localeProvider);
106 this.timeZoneProvider = timeZoneProvider;
107 this.startTimeStabilizer = new TimeStabilizer();
108 this.finishTimeStabilizer = new TimeStabilizer();
111 public ApplianceChannelSelector getValueSelectorFromChannelID(String valueSelectorText)
112 throws IllegalArgumentException {
113 E[] enumConstants = selectorType.getEnumConstants();
114 if (enumConstants == null) {
115 throw new IllegalArgumentException(
116 String.format("Could not get enum constants for value selector: %s", valueSelectorText));
118 for (ApplianceChannelSelector c : enumConstants) {
119 if (c.getChannelID().equals(valueSelectorText)) {
124 throw new IllegalArgumentException(String.format("Not valid value selector: %s", valueSelectorText));
127 public ApplianceChannelSelector getValueSelectorFromMieleID(String valueSelectorText)
128 throws IllegalArgumentException {
129 E[] enumConstants = selectorType.getEnumConstants();
130 if (enumConstants == null) {
131 throw new IllegalArgumentException(
132 String.format("Could not get enum constants for value selector: %s", valueSelectorText));
134 for (ApplianceChannelSelector c : enumConstants) {
135 if (!c.getMieleID().isEmpty() && c.getMieleID().equals(valueSelectorText)) {
140 throw new IllegalArgumentException(String.format("Not valid value selector: %s", valueSelectorText));
144 public void initialize() {
145 logger.debug("Initializing handler for thing {}", getThing().getUID());
146 final String applianceId = (String) getThing().getConfiguration().getProperties().get(APPLIANCE_ID);
147 if (applianceId == null || applianceId.isBlank()) {
148 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
149 "@text/offline.configuration-error.uid-not-set");
152 this.applianceId = applianceId;
153 Bridge bridge = getBridge();
154 if (bridge == null) {
155 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
156 "@text/offline.configuration-error.bridge-missing");
159 initializeTranslationProvider(bridge);
160 updateStatus(ThingStatus.UNKNOWN);
162 MieleBridgeHandler bridgeHandler = getMieleBridgeHandler();
163 if (bridgeHandler != null) {
164 bridgeHandler.registerApplianceStatusListener(applianceId, this);
168 private void initializeTranslationProvider(Bridge bridge) {
169 Locale locale = null;
170 String language = (String) bridge.getConfiguration().get(LANGUAGE);
171 if (language != null && !language.isBlank()) {
173 locale = new Locale.Builder().setLanguageTag(language).build();
174 } catch (IllformedLocaleException e) {
175 logger.warn("Invalid language configured: {}", e.getMessage());
178 if (locale == null) {
179 logger.debug("No language configured, using system language.");
180 this.translationProvider = new MieleTranslationProvider(i18nProvider, localeProvider);
182 this.translationProvider = new MieleTranslationProvider(i18nProvider, localeProvider, locale);
187 public void dispose() {
188 logger.debug("Handler disposes. Unregistering listener.");
189 String applianceId = this.applianceId;
190 if (applianceId != null) {
191 MieleBridgeHandler bridgeHandler = getMieleBridgeHandler();
192 if (bridgeHandler != null) {
193 bridgeHandler.unregisterApplianceStatusListener(applianceId, this);
197 startTimeStabilizer.clear();
198 finishTimeStabilizer.clear();
202 public void handleCommand(ChannelUID channelUID, Command command) {
203 // Here we could handle commands that are common to all Miele Appliances, but so far I don't know of any
204 if (command instanceof RefreshType) {
205 // Placeholder for future refinement
211 public void onApplianceStateChanged(DeviceClassObject dco) {
212 JsonArray properties = dco.Properties;
213 if (properties == null) {
217 for (JsonElement prop : properties.getAsJsonArray()) {
219 DeviceProperty dp = gson.fromJson(prop, DeviceProperty.class);
223 if (!EXTENDED_DEVICE_STATE_PROPERTY_NAME.equals(dp.Name)) {
224 dp.Value = dp.Value.trim();
225 dp.Value = dp.Value.strip();
227 onAppliancePropertyChanged(dp);
228 } catch (Exception p) {
229 // Ignore - this is due to an unrecognized and not yet reverse-engineered array property
235 public void onAppliancePropertyChanged(DeviceProperty dp) {
237 DeviceMetaData dmd = null;
238 if (dp.Metadata == null) {
239 String metadata = metaDataCache.get(new StringBuilder().append(dp.Name).toString().trim());
240 if (metadata != null) {
241 JsonObject jsonMetadata = (JsonObject) JsonParser.parseString(metadata);
242 dmd = gson.fromJson(jsonMetadata, DeviceMetaData.class);
243 // only keep the enum, if any - that's all we care for events we receive via multicast
244 // all other fields are nulled
246 dmd.LocalizedID = null;
247 dmd.LocalizedValue = null;
249 dmd.description = null;
253 JsonObject jsonMetadata = dp.Metadata;
254 if (jsonMetadata != null) {
255 String metadata = jsonMetadata.toString().replace("enum", "MieleEnum");
256 JsonObject jsonMetaData = (JsonObject) JsonParser.parseString(metadata);
257 dmd = gson.fromJson(jsonMetaData, DeviceMetaData.class);
258 metaDataCache.put(new StringBuilder().append(dp.Name).toString().trim(), metadata);
261 ThingUID thingUid = getThing().getUID();
262 if (EXTENDED_DEVICE_STATE_PROPERTY_NAME.equals(dp.Name)) {
263 if (!dp.Value.isEmpty()) {
264 byte[] extendedStateBytes = DeviceUtil.stringToBytes(dp.Value);
265 logger.trace("Extended device state for {}: {}", getThing().getUID(),
266 DeviceUtil.bytesToHex(extendedStateBytes));
267 if (this instanceof ExtendedDeviceStateListener) {
268 ((ExtendedDeviceStateListener) this).onApplianceExtendedStateChanged(extendedStateBytes);
272 } else if (START_TIME_PROPERTY_NAME.equals(dp.Name)) {
273 updateStateFromTime(new ChannelUID(thingUid, START_CHANNEL_ID), dp.Value, startTimeStabilizer);
275 } else if (FINISH_TIME_PROPERTY_NAME.equals(dp.Name)) {
276 updateDurationState(new ChannelUID(thingUid, FINISH_CHANNEL_ID), dp.Value);
277 updateStateFromTime(new ChannelUID(thingUid, END_CHANNEL_ID), dp.Value, finishTimeStabilizer);
281 ApplianceChannelSelector selector = null;
283 selector = getValueSelectorFromMieleID(dp.Name);
284 } catch (Exception h) {
285 logger.trace("{} is not a valid channel for a {}", dp.Name, modelID);
288 String dpValue = dp.Value.strip().trim();
290 if (selector != null) {
291 String channelId = selector.getChannelID();
292 State state = selector.getState(dpValue, dmd, this.translationProvider);
293 if (selector.isProperty()) {
294 String value = state.toString();
295 logger.trace("Updating the property '{}' of '{}' to '{}'", channelId, thingUid, value);
296 updateProperty(channelId, value);
298 ChannelUID theChannelUID = new ChannelUID(thingUid, channelId);
299 logger.trace("Update state of {} with getState '{}'", theChannelUID, state);
300 updateState(theChannelUID, state);
301 updateRawChannel(dp.Name, dpValue);
304 } catch (IllegalArgumentException e) {
305 logger.warn("An exception occurred while processing a changed device property: '{}'", e.getMessage());
309 protected void updateExtendedState(String channelId, State state) {
310 ChannelUID channelUid = new ChannelUID(getThing().getUID(), channelId);
311 logger.trace("Update state of {} with extended state '{}'", channelUid, state);
312 updateState(channelUid, state);
315 private void updateStateFromTime(ChannelUID channelUid, String value, TimeStabilizer stabilizer) {
317 long minutesFromNow = Long.valueOf(value);
318 if (minutesFromNow > 0) {
319 Instant rawTime = Instant.now().truncatedTo(ChronoUnit.MINUTES).plusSeconds(minutesFromNow * 60);
320 ZonedDateTime correctedTime = stabilizer.apply(rawTime).atZone(timeZoneProvider.getTimeZone());
321 ZonedDateTime truncatedTime = correctedTime.truncatedTo(ChronoUnit.MINUTES);
322 logger.trace("Update state of {} from {} -> '{}' -> '{}' to '{}'", channelUid, minutesFromNow, rawTime,
323 correctedTime, truncatedTime);
324 updateState(channelUid, new DateTimeType(truncatedTime));
327 } catch (NumberFormatException e) {
330 updateState(channelUid, UnDefType.UNDEF);
334 private void updateDurationState(ChannelUID channelUid, String value) {
336 long minutesFromNow = Long.valueOf(value);
337 if (minutesFromNow > 0) {
338 ZonedDateTime remaining = ZonedDateTime.ofInstant(Instant.ofEpochSecond(minutesFromNow * 60),
340 updateState(channelUid, new DateTimeType(remaining.withZoneSameLocal(timeZoneProvider.getTimeZone())));
343 } catch (NumberFormatException e) {
346 updateState(channelUid, UnDefType.UNDEF);
349 protected void updateSwitchOnOffFromState(DeviceProperty dp) {
350 if (!STATE_PROPERTY_NAME.equals(dp.Name)) {
354 // Switch is trigger channel, but current state can be deduced from state.
355 ChannelUID channelUid = new ChannelUID(getThing().getUID(), SWITCH_CHANNEL_ID);
356 State state = OnOffType.from(!dp.Value.equals(String.valueOf(STATE_OFF)));
357 logger.trace("Update state of {} to {} through '{}'", channelUid, state, dp.Name);
358 updateState(channelUid, state);
361 protected void updateSwitchStartStopFromState(DeviceProperty dp) {
362 if (!STATE_PROPERTY_NAME.equals(dp.Name)) {
366 // Switch is trigger channel, but current state can be deduced from state.
367 ChannelUID channelUid = new ChannelUID(getThing().getUID(), SWITCH_CHANNEL_ID);
368 State state = OnOffType.from(dp.Value.equals(String.valueOf(STATE_RUNNING)));
369 logger.trace("Update state of {} to {} through '{}'", channelUid, state, dp.Name);
370 updateState(channelUid, state);
374 * Update raw value channels for properties already mapped to text channels.
375 * Currently ApplianceChannelSelector only supports 1:1 mapping from property
378 private void updateRawChannel(String propertyName, String value) {
380 switch (propertyName) {
381 case STATE_PROPERTY_NAME:
382 channelId = STATE_CHANNEL_ID;
384 case PROGRAM_ID_PROPERTY_NAME:
385 channelId = PROGRAM_CHANNEL_ID;
390 ApplianceChannelSelector selector = null;
392 selector = getValueSelectorFromChannelID(channelId);
393 } catch (IllegalArgumentException e) {
394 logger.trace("{} is not a valid channel for a {}", channelId, modelID);
397 ChannelUID channelUid = new ChannelUID(getThing().getUID(), channelId);
398 State state = selector.getState(value);
399 logger.trace("Update state of {} with getState '{}'", channelUid, state);
400 updateState(channelUid, state);
404 public void onApplianceRemoved() {
405 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.GONE);
409 public void onApplianceAdded(HomeDevice appliance) {
410 Map<String, String> properties = editProperties();
411 String vendor = appliance.Vendor;
412 if (vendor != null) {
413 properties.put(Thing.PROPERTY_VENDOR, vendor);
415 properties.put(Thing.PROPERTY_MODEL_ID, appliance.getApplianceModel());
416 properties.put(Thing.PROPERTY_SERIAL_NUMBER, appliance.getSerialNumber());
417 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, appliance.getFirmwareVersion());
418 String protocolAdapterName = appliance.ProtocolAdapterName;
419 if (protocolAdapterName != null) {
420 properties.put(PROPERTY_PROTOCOL_ADAPTER, protocolAdapterName);
422 String deviceClass = appliance.getDeviceClass();
423 if (deviceClass != null) {
424 properties.put(PROPERTY_DEVICE_CLASS, deviceClass);
426 String connectionType = appliance.getConnectionType();
427 if (connectionType != null) {
428 properties.put(PROPERTY_CONNECTION_TYPE, connectionType);
430 String connectionBaudRate = appliance.getConnectionBaudRate();
431 if (connectionBaudRate != null) {
432 properties.put(PROPERTY_CONNECTION_BAUD_RATE, connectionBaudRate);
434 updateProperties(properties);
435 updateStatus(ThingStatus.ONLINE);
438 protected synchronized @Nullable MieleBridgeHandler getMieleBridgeHandler() {
439 if (this.bridgeHandler == null) {
440 Bridge bridge = getBridge();
441 if (bridge == null) {
444 ThingHandler handler = bridge.getHandler();
445 if (handler instanceof MieleBridgeHandler) {
446 this.bridgeHandler = (MieleBridgeHandler) handler;
449 return this.bridgeHandler;
452 protected boolean isResultProcessable(JsonElement result) {
453 return !result.isJsonNull();