]> git.basschouten.com Git - openhab-addons.git/blob
7d29682a5c984ac38d81744937c52d52b5fec5e4
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.miele.internal.handler;
14
15 import static org.openhab.binding.miele.internal.MieleBindingConstants.*;
16
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;
23 import java.util.Map;
24 import java.util.Set;
25
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;
57
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;
63
64 /**
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
69  *
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)
73  */
74 @NonNullByDefault
75 public abstract class MieleApplianceHandler<E extends Enum<E> & ApplianceChannelSelector> extends BaseThingHandler
76         implements ApplianceStatusListener {
77
78     private final Logger logger = LoggerFactory.getLogger(MieleApplianceHandler.class);
79
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);
83
84     protected Gson gson = new Gson();
85
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;
96
97     protected Map<String, String> metaDataCache = new HashMap<>();
98
99     public MieleApplianceHandler(Thing thing, TranslationProvider i18nProvider, LocaleProvider localeProvider,
100             TimeZoneProvider timeZoneProvider, Class<E> selectorType, String modelID) {
101         super(thing);
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();
110     }
111
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));
118         }
119         for (ApplianceChannelSelector c : enumConstants) {
120             if (c.getChannelID().equals(valueSelectorText)) {
121                 return c;
122             }
123         }
124
125         throw new IllegalArgumentException(String.format("Not valid value selector: %s", valueSelectorText));
126     }
127
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));
134         }
135         for (ApplianceChannelSelector c : enumConstants) {
136             if (!c.getMieleID().isEmpty() && c.getMieleID().equals(valueSelectorText)) {
137                 return c;
138             }
139         }
140
141         throw new IllegalArgumentException(String.format("Not valid value selector: %s", valueSelectorText));
142     }
143
144     @Override
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");
151             return;
152         }
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");
158             return;
159         }
160         initializeTranslationProvider(bridge);
161         updateStatus(ThingStatus.UNKNOWN);
162
163         MieleBridgeHandler bridgeHandler = getMieleBridgeHandler();
164         if (bridgeHandler != null) {
165             bridgeHandler.registerApplianceStatusListener(applianceId, this);
166         }
167     }
168
169     private void initializeTranslationProvider(Bridge bridge) {
170         Locale locale = null;
171         String language = (String) bridge.getConfiguration().get(LANGUAGE);
172         if (language != null && !language.isBlank()) {
173             try {
174                 locale = new Locale.Builder().setLanguageTag(language).build();
175             } catch (IllformedLocaleException e) {
176                 logger.warn("Invalid language configured: {}", e.getMessage());
177             }
178         }
179         if (locale == null) {
180             logger.debug("No language configured, using system language.");
181             this.translationProvider = new MieleTranslationProvider(i18nProvider, localeProvider);
182         } else {
183             this.translationProvider = new MieleTranslationProvider(i18nProvider, localeProvider, locale);
184         }
185     }
186
187     @Override
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);
195             }
196             applianceId = null;
197         }
198         startTimeStabilizer.clear();
199         finishTimeStabilizer.clear();
200     }
201
202     @Override
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
207             return;
208         }
209     }
210
211     @Override
212     public void onApplianceStateChanged(DeviceClassObject dco) {
213         JsonArray properties = dco.Properties;
214         if (properties == null) {
215             return;
216         }
217
218         for (JsonElement prop : properties.getAsJsonArray()) {
219             try {
220                 DeviceProperty dp = gson.fromJson(prop, DeviceProperty.class);
221                 if (dp == null) {
222                     continue;
223                 }
224                 if (!EXTENDED_DEVICE_STATE_PROPERTY_NAME.equals(dp.Name)) {
225                     dp.Value = dp.Value.trim();
226                     dp.Value = dp.Value.strip();
227                 }
228                 onAppliancePropertyChanged(dp);
229             } catch (Exception p) {
230                 // Ignore - this is due to an unrecognized and not yet reverse-engineered array property
231             }
232         }
233     }
234
235     @Override
236     public void onAppliancePropertyChanged(DeviceProperty dp) {
237         try {
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
246                     if (dmd != null) {
247                         dmd.LocalizedID = null;
248                         dmd.LocalizedValue = null;
249                         dmd.Filter = null;
250                         dmd.description = null;
251                     }
252                 }
253             }
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);
260             }
261
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);
270                     }
271                 }
272                 return;
273             } else if (START_TIME_PROPERTY_NAME.equals(dp.Name)) {
274                 updateStateFromTime(new ChannelUID(thingUid, START_CHANNEL_ID), dp.Value, startTimeStabilizer);
275                 return;
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);
279                 return;
280             }
281
282             ApplianceChannelSelector selector = null;
283             try {
284                 selector = getValueSelectorFromMieleID(dp.Name);
285             } catch (Exception h) {
286                 logger.trace("{} is not a valid channel for a {}", dp.Name, modelID);
287             }
288
289             String dpValue = dp.Value.strip().trim();
290
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);
298                 } else {
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);
303                 }
304             }
305         } catch (IllegalArgumentException e) {
306             logger.warn("An exception occurred while processing a changed device property: '{}'", e.getMessage());
307         }
308     }
309
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);
314     }
315
316     private void updateStateFromTime(ChannelUID channelUid, String value, TimeStabilizer stabilizer) {
317         try {
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));
326                 return;
327             }
328         } catch (NumberFormatException e) {
329             // Fall through.
330         }
331         updateState(channelUid, UnDefType.UNDEF);
332         stabilizer.clear();
333     }
334
335     private void updateDurationState(ChannelUID channelUid, String value) {
336         try {
337             long minutesFromNow = Long.valueOf(value);
338             if (minutesFromNow > 0) {
339                 updateState(channelUid, new QuantityType<>(minutesFromNow, Units.MINUTE));
340                 return;
341             }
342         } catch (NumberFormatException e) {
343             // Fall through.
344         }
345         updateState(channelUid, UnDefType.UNDEF);
346     }
347
348     protected void updateSwitchOnOffFromState(DeviceProperty dp) {
349         if (!STATE_PROPERTY_NAME.equals(dp.Name)) {
350             return;
351         }
352
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);
358     }
359
360     protected void updateSwitchStartStopFromState(DeviceProperty dp) {
361         if (!STATE_PROPERTY_NAME.equals(dp.Name)) {
362             return;
363         }
364
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         logger.trace("Update state of {} to {} through '{}'", channelUid, state, dp.Name);
369         updateState(channelUid, state);
370     }
371
372     /**
373      * Update raw value channels for properties already mapped to text channels.
374      * Currently ApplianceChannelSelector only supports 1:1 mapping from property
375      * to channel.
376      */
377     private void updateRawChannel(String propertyName, String value) {
378         String channelId;
379         switch (propertyName) {
380             case STATE_PROPERTY_NAME:
381                 channelId = STATE_CHANNEL_ID;
382                 break;
383             case PROGRAM_ID_PROPERTY_NAME:
384                 channelId = PROGRAM_CHANNEL_ID;
385                 break;
386             default:
387                 return;
388         }
389         ApplianceChannelSelector selector = null;
390         try {
391             selector = getValueSelectorFromChannelID(channelId);
392         } catch (IllegalArgumentException e) {
393             logger.trace("{} is not a valid channel for a {}", channelId, modelID);
394             return;
395         }
396         ChannelUID channelUid = new ChannelUID(getThing().getUID(), channelId);
397         State state = selector.getState(value);
398         logger.trace("Update state of {} with getState '{}'", channelUid, state);
399         updateState(channelUid, state);
400     }
401
402     @Override
403     public void onApplianceRemoved() {
404         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.GONE);
405     }
406
407     @Override
408     public void onApplianceAdded(HomeDevice appliance) {
409         Map<String, String> properties = editProperties();
410         String vendor = appliance.Vendor;
411         if (vendor != null) {
412             properties.put(Thing.PROPERTY_VENDOR, vendor);
413         }
414         properties.put(Thing.PROPERTY_MODEL_ID, appliance.getApplianceModel());
415         properties.put(Thing.PROPERTY_SERIAL_NUMBER, appliance.getSerialNumber());
416         properties.put(Thing.PROPERTY_FIRMWARE_VERSION, appliance.getFirmwareVersion());
417         String protocolAdapterName = appliance.ProtocolAdapterName;
418         if (protocolAdapterName != null) {
419             properties.put(PROPERTY_PROTOCOL_ADAPTER, protocolAdapterName);
420         }
421         String deviceClass = appliance.getDeviceClass();
422         if (deviceClass != null) {
423             properties.put(PROPERTY_DEVICE_CLASS, deviceClass);
424         }
425         String connectionType = appliance.getConnectionType();
426         if (connectionType != null) {
427             properties.put(PROPERTY_CONNECTION_TYPE, connectionType);
428         }
429         String connectionBaudRate = appliance.getConnectionBaudRate();
430         if (connectionBaudRate != null) {
431             properties.put(PROPERTY_CONNECTION_BAUD_RATE, connectionBaudRate);
432         }
433         updateProperties(properties);
434         updateStatus(ThingStatus.ONLINE);
435     }
436
437     protected synchronized @Nullable MieleBridgeHandler getMieleBridgeHandler() {
438         if (this.bridgeHandler == null) {
439             Bridge bridge = getBridge();
440             if (bridge == null) {
441                 return null;
442             }
443             ThingHandler handler = bridge.getHandler();
444             if (handler instanceof MieleBridgeHandler mieleBridgeHandler) {
445                 this.bridgeHandler = mieleBridgeHandler;
446             }
447         }
448         return this.bridgeHandler;
449     }
450
451     protected boolean isResultProcessable(JsonElement result) {
452         return !result.isJsonNull();
453     }
454 }