2 * Copyright (c) 2010-2024 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.ZonedDateTime;
19 import java.time.temporal.ChronoUnit;
20 import java.util.HashMap;
21 import java.util.IllformedLocaleException;
22 import java.util.Locale;
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.openhab.binding.miele.internal.DeviceUtil;
29 import org.openhab.binding.miele.internal.MieleTranslationProvider;
30 import org.openhab.binding.miele.internal.TimeStabilizer;
31 import org.openhab.binding.miele.internal.api.dto.DeviceClassObject;
32 import org.openhab.binding.miele.internal.api.dto.DeviceMetaData;
33 import org.openhab.binding.miele.internal.api.dto.DeviceProperty;
34 import org.openhab.binding.miele.internal.api.dto.HomeDevice;
35 import org.openhab.core.i18n.LocaleProvider;
36 import org.openhab.core.i18n.TimeZoneProvider;
37 import org.openhab.core.i18n.TranslationProvider;
38 import org.openhab.core.library.types.DateTimeType;
39 import org.openhab.core.library.types.OnOffType;
40 import org.openhab.core.library.types.QuantityType;
41 import org.openhab.core.library.unit.Units;
42 import org.openhab.core.thing.Bridge;
43 import org.openhab.core.thing.ChannelUID;
44 import org.openhab.core.thing.Thing;
45 import org.openhab.core.thing.ThingStatus;
46 import org.openhab.core.thing.ThingStatusDetail;
47 import org.openhab.core.thing.ThingTypeUID;
48 import org.openhab.core.thing.ThingUID;
49 import org.openhab.core.thing.binding.BaseThingHandler;
50 import org.openhab.core.thing.binding.ThingHandler;
51 import org.openhab.core.types.Command;
52 import org.openhab.core.types.RefreshType;
53 import org.openhab.core.types.State;
54 import org.openhab.core.types.UnDefType;
55 import org.slf4j.Logger;
56 import org.slf4j.LoggerFactory;
58 import com.google.gson.Gson;
59 import com.google.gson.JsonArray;
60 import com.google.gson.JsonElement;
61 import com.google.gson.JsonObject;
62 import com.google.gson.JsonParser;
65 * The {@link MieleApplianceHandler} is an abstract class
66 * responsible for handling commands, which are sent to one
67 * of the channels of the appliance that understands/"talks"
68 * the {@link ApplianceChannelSelector} datapoints
70 * @author Karel Goderis - Initial contribution
71 * @author Martin Lepsy - Added check for JsonNull result
72 * @author Jacob Laursen - Fixed multicast and protocol support (ZigBee/LAN)
75 public abstract class MieleApplianceHandler<E extends Enum<E> & ApplianceChannelSelector> extends BaseThingHandler
76 implements ApplianceStatusListener {
78 private final Logger logger = LoggerFactory.getLogger(MieleApplianceHandler.class);
80 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_DISHWASHER, THING_TYPE_OVEN,
81 THING_TYPE_FRIDGE, THING_TYPE_DRYER, THING_TYPE_HOB, THING_TYPE_FRIDGEFREEZER, THING_TYPE_HOOD,
82 THING_TYPE_WASHINGMACHINE, THING_TYPE_COFFEEMACHINE);
84 protected Gson gson = new Gson();
86 protected @Nullable String applianceId;
87 private @Nullable MieleBridgeHandler bridgeHandler;
88 protected TranslationProvider i18nProvider;
89 protected LocaleProvider localeProvider;
90 protected MieleTranslationProvider translationProvider;
91 private TimeZoneProvider timeZoneProvider;
92 private TimeStabilizer startTimeStabilizer;
93 private TimeStabilizer finishTimeStabilizer;
94 private Class<E> selectorType;
95 protected String modelID;
97 protected Map<String, String> metaDataCache = new HashMap<>();
99 public MieleApplianceHandler(Thing thing, TranslationProvider i18nProvider, LocaleProvider localeProvider,
100 TimeZoneProvider timeZoneProvider, Class<E> selectorType, String modelID) {
102 this.i18nProvider = i18nProvider;
103 this.localeProvider = localeProvider;
104 this.selectorType = selectorType;
105 this.modelID = modelID;
106 this.translationProvider = new MieleTranslationProvider(i18nProvider, localeProvider);
107 this.timeZoneProvider = timeZoneProvider;
108 this.startTimeStabilizer = new TimeStabilizer();
109 this.finishTimeStabilizer = new TimeStabilizer();
112 public ApplianceChannelSelector getValueSelectorFromChannelID(String valueSelectorText)
113 throws IllegalArgumentException {
114 E[] enumConstants = selectorType.getEnumConstants();
115 if (enumConstants == null) {
116 throw new IllegalArgumentException(
117 String.format("Could not get enum constants for value selector: %s", valueSelectorText));
119 for (ApplianceChannelSelector c : enumConstants) {
120 if (c.getChannelID().equals(valueSelectorText)) {
125 throw new IllegalArgumentException(String.format("Not valid value selector: %s", valueSelectorText));
128 public ApplianceChannelSelector getValueSelectorFromMieleID(String valueSelectorText)
129 throws IllegalArgumentException {
130 E[] enumConstants = selectorType.getEnumConstants();
131 if (enumConstants == null) {
132 throw new IllegalArgumentException(
133 String.format("Could not get enum constants for value selector: %s", valueSelectorText));
135 for (ApplianceChannelSelector c : enumConstants) {
136 if (!c.getMieleID().isEmpty() && c.getMieleID().equals(valueSelectorText)) {
141 throw new IllegalArgumentException(String.format("Not valid value selector: %s", valueSelectorText));
145 public void initialize() {
146 logger.debug("Initializing handler for thing {}", getThing().getUID());
147 final String applianceId = (String) getThing().getConfiguration().getProperties().get(APPLIANCE_ID);
148 if (applianceId == null || applianceId.isBlank()) {
149 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
150 "@text/offline.configuration-error.uid-not-set");
153 this.applianceId = applianceId;
154 Bridge bridge = getBridge();
155 if (bridge == null) {
156 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
157 "@text/offline.configuration-error.bridge-missing");
160 initializeTranslationProvider(bridge);
161 updateStatus(ThingStatus.UNKNOWN);
163 MieleBridgeHandler bridgeHandler = getMieleBridgeHandler();
164 if (bridgeHandler != null) {
165 bridgeHandler.registerApplianceStatusListener(applianceId, this);
169 private void initializeTranslationProvider(Bridge bridge) {
170 Locale locale = null;
171 String language = (String) bridge.getConfiguration().get(LANGUAGE);
172 if (language != null && !language.isBlank()) {
174 locale = new Locale.Builder().setLanguageTag(language).build();
175 } catch (IllformedLocaleException e) {
176 logger.warn("Invalid language configured: {}", e.getMessage());
179 if (locale == null) {
180 logger.debug("No language configured, using system language.");
181 this.translationProvider = new MieleTranslationProvider(i18nProvider, localeProvider);
183 this.translationProvider = new MieleTranslationProvider(i18nProvider, localeProvider, locale);
188 public void dispose() {
189 logger.debug("Handler disposes. Unregistering listener.");
190 String applianceId = this.applianceId;
191 if (applianceId != null) {
192 MieleBridgeHandler bridgeHandler = getMieleBridgeHandler();
193 if (bridgeHandler != null) {
194 bridgeHandler.unregisterApplianceStatusListener(applianceId, this);
198 startTimeStabilizer.clear();
199 finishTimeStabilizer.clear();
203 public void handleCommand(ChannelUID channelUID, Command command) {
204 // Here we could handle commands that are common to all Miele Appliances, but so far I don't know of any
205 if (command instanceof RefreshType) {
206 // Placeholder for future refinement
212 public void onApplianceStateChanged(DeviceClassObject dco) {
213 JsonArray properties = dco.Properties;
214 if (properties == null) {
218 for (JsonElement prop : properties.getAsJsonArray()) {
220 DeviceProperty dp = gson.fromJson(prop, DeviceProperty.class);
224 if (!EXTENDED_DEVICE_STATE_PROPERTY_NAME.equals(dp.Name)) {
225 dp.Value = dp.Value.trim();
226 dp.Value = dp.Value.strip();
228 onAppliancePropertyChanged(dp);
229 } catch (Exception p) {
230 // Ignore - this is due to an unrecognized and not yet reverse-engineered array property
236 public void onAppliancePropertyChanged(DeviceProperty dp) {
238 DeviceMetaData dmd = null;
239 if (dp.Metadata == null) {
240 String metadata = metaDataCache.get(new StringBuilder().append(dp.Name).toString().trim());
241 if (metadata != null) {
242 JsonObject jsonMetadata = (JsonObject) JsonParser.parseString(metadata);
243 dmd = gson.fromJson(jsonMetadata, DeviceMetaData.class);
244 // only keep the enum, if any - that's all we care for events we receive via multicast
245 // all other fields are nulled
247 dmd.LocalizedID = null;
248 dmd.LocalizedValue = null;
250 dmd.description = null;
254 JsonObject jsonMetadata = dp.Metadata;
255 if (jsonMetadata != null) {
256 String metadata = jsonMetadata.toString().replace("enum", "MieleEnum");
257 JsonObject jsonMetaData = (JsonObject) JsonParser.parseString(metadata);
258 dmd = gson.fromJson(jsonMetaData, DeviceMetaData.class);
259 metaDataCache.put(new StringBuilder().append(dp.Name).toString().trim(), metadata);
262 ThingUID thingUid = getThing().getUID();
263 if (EXTENDED_DEVICE_STATE_PROPERTY_NAME.equals(dp.Name)) {
264 if (!dp.Value.isEmpty()) {
265 byte[] extendedStateBytes = DeviceUtil.stringToBytes(dp.Value);
266 logger.trace("Extended device state for {}: {}", getThing().getUID(),
267 DeviceUtil.bytesToHex(extendedStateBytes));
268 if (this instanceof ExtendedDeviceStateListener listener) {
269 listener.onApplianceExtendedStateChanged(extendedStateBytes);
273 } else if (START_TIME_PROPERTY_NAME.equals(dp.Name)) {
274 updateStateFromTime(new ChannelUID(thingUid, START_CHANNEL_ID), dp.Value, startTimeStabilizer);
276 } else if (FINISH_TIME_PROPERTY_NAME.equals(dp.Name)) {
277 updateDurationState(new ChannelUID(thingUid, FINISH_CHANNEL_ID), dp.Value);
278 updateStateFromTime(new ChannelUID(thingUid, END_CHANNEL_ID), dp.Value, finishTimeStabilizer);
282 ApplianceChannelSelector selector = null;
284 selector = getValueSelectorFromMieleID(dp.Name);
285 } catch (Exception h) {
286 logger.trace("{} is not a valid channel for a {}", dp.Name, modelID);
289 String dpValue = dp.Value.strip().trim();
291 if (selector != null) {
292 String channelId = selector.getChannelID();
293 State state = selector.getState(dpValue, dmd, this.translationProvider);
294 if (selector.isProperty()) {
295 String value = state.toString();
296 logger.trace("Updating the property '{}' of '{}' to '{}'", channelId, thingUid, value);
297 updateProperty(channelId, value);
299 ChannelUID theChannelUID = new ChannelUID(thingUid, channelId);
300 logger.trace("Update state of {} with getState '{}'", theChannelUID, state);
301 updateState(theChannelUID, state);
302 updateRawChannel(dp.Name, dpValue);
305 } catch (IllegalArgumentException e) {
306 logger.warn("An exception occurred while processing a changed device property: '{}'", e.getMessage());
310 protected void updateExtendedState(String channelId, State state) {
311 ChannelUID channelUid = new ChannelUID(getThing().getUID(), channelId);
312 logger.trace("Update state of {} with extended state '{}'", channelUid, state);
313 updateState(channelUid, state);
316 private void updateStateFromTime(ChannelUID channelUid, String value, TimeStabilizer stabilizer) {
318 long minutesFromNow = Long.valueOf(value);
319 if (minutesFromNow > 0) {
320 Instant rawTime = Instant.now().truncatedTo(ChronoUnit.MINUTES).plusSeconds(minutesFromNow * 60);
321 ZonedDateTime correctedTime = stabilizer.apply(rawTime).atZone(timeZoneProvider.getTimeZone());
322 ZonedDateTime truncatedTime = correctedTime.truncatedTo(ChronoUnit.MINUTES);
323 logger.trace("Update state of {} from {} -> '{}' -> '{}' to '{}'", channelUid, minutesFromNow, rawTime,
324 correctedTime, truncatedTime);
325 updateState(channelUid, new DateTimeType(truncatedTime));
328 } catch (NumberFormatException e) {
331 updateState(channelUid, UnDefType.UNDEF);
335 private void updateDurationState(ChannelUID channelUid, String value) {
337 long minutesFromNow = Long.valueOf(value);
338 if (minutesFromNow > 0) {
339 updateState(channelUid, new QuantityType<>(minutesFromNow, Units.MINUTE));
342 } catch (NumberFormatException e) {
345 updateState(channelUid, UnDefType.UNDEF);
348 protected void updateSwitchOnOffFromState(DeviceProperty dp) {
349 if (!STATE_PROPERTY_NAME.equals(dp.Name)) {
353 // Switch is trigger channel, but current state can be deduced from state.
354 ChannelUID channelUid = new ChannelUID(getThing().getUID(), SWITCH_CHANNEL_ID);
355 State state = OnOffType.from(!dp.Value.equals(String.valueOf(STATE_OFF)));
356 logger.trace("Update state of {} to {} through '{}'", channelUid, state, dp.Name);
357 updateState(channelUid, state);
360 protected void updateSwitchStartStopFromState(DeviceProperty dp) {
361 if (!STATE_PROPERTY_NAME.equals(dp.Name)) {
365 // Switch is trigger channel, but current state can be deduced from state.
366 ChannelUID channelUid = new ChannelUID(getThing().getUID(), SWITCH_CHANNEL_ID);
367 State state = OnOffType.from(dp.Value.equals(String.valueOf(STATE_RUNNING))
368 || dp.Value.equals(String.valueOf(STATE_END)) || dp.Value.equals(String.valueOf(STATE_RINSE_HOLD)));
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 mieleBridgeHandler) {
446 this.bridgeHandler = mieleBridgeHandler;
449 return this.bridgeHandler;
452 protected boolean isResultProcessable(JsonElement result) {
453 return !result.isJsonNull();