]> git.basschouten.com Git - openhab-addons.git/blob
2e5ebbe1c192377137b41dcfdeb5aa087f919076
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.wemo.internal.handler;
14
15 import static org.openhab.binding.wemo.internal.WemoBindingConstants.*;
16
17 import java.io.StringReader;
18 import java.math.BigDecimal;
19 import java.net.URL;
20 import java.time.Instant;
21 import java.time.ZonedDateTime;
22 import java.util.Collections;
23 import java.util.HashMap;
24 import java.util.Map;
25 import java.util.Set;
26 import java.util.TimeZone;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
29
30 import javax.xml.parsers.DocumentBuilder;
31 import javax.xml.parsers.DocumentBuilderFactory;
32
33 import org.apache.commons.lang3.StringEscapeUtils;
34 import org.apache.commons.lang3.StringUtils;
35 import org.openhab.binding.wemo.internal.http.WemoHttpCall;
36 import org.openhab.core.config.core.Configuration;
37 import org.openhab.core.io.transport.upnp.UpnpIOParticipant;
38 import org.openhab.core.io.transport.upnp.UpnpIOService;
39 import org.openhab.core.library.types.DateTimeType;
40 import org.openhab.core.library.types.DecimalType;
41 import org.openhab.core.library.types.OnOffType;
42 import org.openhab.core.library.types.StringType;
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.types.Command;
49 import org.openhab.core.types.RefreshType;
50 import org.openhab.core.types.State;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
53 import org.w3c.dom.CharacterData;
54 import org.w3c.dom.Document;
55 import org.w3c.dom.Element;
56 import org.w3c.dom.Node;
57 import org.w3c.dom.NodeList;
58 import org.xml.sax.InputSource;
59
60 /**
61  * The {@link WemoCoffeeHandler} is responsible for handling commands, which are
62  * sent to one of the channels and to update their states.
63  *
64  * @author Hans-Jörg Merk - Initial contribution
65  * @author Erdoan Hadzhiyusein - Adapted the class to work with the new DateTimeType
66  */
67
68 public class WemoCoffeeHandler extends AbstractWemoHandler implements UpnpIOParticipant {
69
70     private final Logger logger = LoggerFactory.getLogger(WemoCoffeeHandler.class);
71
72     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_COFFEE);
73
74     private Map<String, Boolean> subscriptionState = new HashMap<>();
75
76     protected static final int SUBSCRIPTION_DURATION = 600;
77
78     private UpnpIOService service;
79
80     /**
81      * The default refresh interval in Seconds.
82      */
83     private final int REFRESH_INTERVAL = 60;
84
85     private ScheduledFuture<?> refreshJob;
86
87     private final Runnable refreshRunnable = new Runnable() {
88
89         @Override
90         public void run() {
91             try {
92                 if (!isUpnpDeviceRegistered()) {
93                     logger.debug("WeMo UPnP device {} not yet registered", getUDN());
94                 }
95
96                 updateWemoState();
97                 onSubscription();
98             } catch (Exception e) {
99                 logger.debug("Exception during poll", e);
100                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
101             }
102         }
103     };
104
105     public WemoCoffeeHandler(Thing thing, UpnpIOService upnpIOService, WemoHttpCall wemoHttpcaller) {
106         super(thing);
107
108         this.wemoHttpCaller = wemoHttpcaller;
109
110         logger.debug("Creating a WemoCoffeeHandler V0.4 for thing '{}'", getThing().getUID());
111
112         if (upnpIOService != null) {
113             this.service = upnpIOService;
114         } else {
115             logger.debug("upnpIOService not set.");
116         }
117     }
118
119     @Override
120     public void initialize() {
121         Configuration configuration = getConfig();
122
123         if (configuration.get("udn") != null) {
124             logger.debug("Initializing WemoCoffeeHandler for UDN '{}'", configuration.get("udn"));
125             onSubscription();
126             onUpdate();
127             updateStatus(ThingStatus.ONLINE);
128         } else {
129             logger.debug("Cannot initalize WemoCoffeeHandler. UDN not set.");
130         }
131     }
132
133     @Override
134     public void dispose() {
135         logger.debug("WeMoCoffeeHandler disposed.");
136
137         removeSubscription();
138
139         if (refreshJob != null && !refreshJob.isCancelled()) {
140             refreshJob.cancel(true);
141             refreshJob = null;
142         }
143     }
144
145     @Override
146     public void handleCommand(ChannelUID channelUID, Command command) {
147         logger.trace("Command '{}' received for channel '{}'", command, channelUID);
148
149         if (command instanceof RefreshType) {
150             try {
151                 updateWemoState();
152             } catch (Exception e) {
153                 logger.debug("Exception during poll", e);
154             }
155         } else if (channelUID.getId().equals(CHANNEL_STATE)) {
156             if (command instanceof OnOffType) {
157                 if (command.equals(OnOffType.ON)) {
158                     try {
159                         String soapHeader = "\"urn:Belkin:service:deviceevent:1#SetAttributes\"";
160
161                         String content = "<?xml version=\"1.0\"?>"
162                                 + "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">"
163                                 + "<s:Body>" + "<u:SetAttributes xmlns:u=\"urn:Belkin:service:deviceevent:1\">"
164                                 + "<attributeList>&lt;attribute&gt;&lt;name&gt;Brewed&lt;/name&gt;&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;"
165                                 + "&lt;attribute&gt;&lt;name&gt;LastCleaned&lt;/name&gt;&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;&lt;attribute&gt;"
166                                 + "&lt;name&gt;ModeTime&lt;/name&gt;&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;&lt;attribute&gt;&lt;name&gt;Brewing&lt;/name&gt;"
167                                 + "&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;&lt;attribute&gt;&lt;name&gt;TimeRemaining&lt;/name&gt;&lt;value&gt;NULL&lt;/value&gt;"
168                                 + "&lt;/attribute&gt;&lt;attribute&gt;&lt;name&gt;WaterLevelReached&lt;/name&gt;&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;&lt;"
169                                 + "attribute&gt;&lt;name&gt;Mode&lt;/name&gt;&lt;value&gt;4&lt;/value&gt;&lt;/attribute&gt;&lt;attribute&gt;&lt;name&gt;CleanAdvise&lt;/name&gt;"
170                                 + "&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;&lt;attribute&gt;&lt;name&gt;FilterAdvise&lt;/name&gt;&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;"
171                                 + "&lt;attribute&gt;&lt;name&gt;Cleaning&lt;/name&gt;&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;</attributeList>"
172                                 + "</u:SetAttributes>" + "</s:Body>" + "</s:Envelope>";
173
174                         String wemoURL = getWemoURL("deviceevent");
175
176                         if (wemoURL != null) {
177                             String wemoCallResponse = wemoHttpCaller.executeCall(wemoURL, soapHeader, content);
178                             if (wemoCallResponse != null) {
179                                 updateState(CHANNEL_STATE, OnOffType.ON);
180                                 State newMode = new StringType("Brewing");
181                                 updateState(CHANNEL_COFFEEMODE, newMode);
182                             }
183                         }
184                     } catch (Exception e) {
185                         logger.error("Failed to send command '{}' for device '{}': {}", command, getThing().getUID(),
186                                 e.getMessage());
187                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
188                     }
189                 }
190                 // if command.equals(OnOffType.OFF) we do nothing because WeMo Coffee Maker cannot be switched off
191                 // remotely
192                 updateStatus(ThingStatus.ONLINE);
193             }
194         }
195     }
196
197     @Override
198     public void onServiceSubscribed(String service, boolean succeeded) {
199         logger.debug("WeMo {}: Subscription to service {} {}", getUDN(), service, succeeded ? "succeeded" : "failed");
200         subscriptionState.put(service, succeeded);
201     }
202
203     @Override
204     public void onValueReceived(String variable, String value, String service) {
205         // We can subscribe to GENA events, but there is no usefull response right now.
206     }
207
208     private synchronized void onSubscription() {
209         if (service.isRegistered(this)) {
210             logger.debug("Checking WeMo GENA subscription for '{}'", this);
211
212             String subscription = "deviceevent1";
213             if ((subscriptionState.get(subscription) == null) || !subscriptionState.get(subscription).booleanValue()) {
214                 logger.debug("Setting up GENA subscription {}: Subscribing to service {}...", getUDN(), subscription);
215                 service.addSubscription(this, subscription, SUBSCRIPTION_DURATION);
216                 subscriptionState.put(subscription, true);
217             }
218         } else {
219             logger.debug("Setting up WeMo GENA subscription for '{}' FAILED - service.isRegistered(this) is FALSE",
220                     this);
221         }
222     }
223
224     private synchronized void removeSubscription() {
225         logger.debug("Removing WeMo GENA subscription for '{}'", this);
226
227         if (service.isRegistered(this)) {
228             String subscription = "deviceevent1";
229             if ((subscriptionState.get(subscription) != null) && subscriptionState.get(subscription).booleanValue()) {
230                 logger.debug("WeMo {}: Unsubscribing from service {}...", getUDN(), subscription);
231                 service.removeSubscription(this, subscription);
232             }
233
234             subscriptionState = new HashMap<>();
235             service.unregisterParticipant(this);
236         }
237     }
238
239     private synchronized void onUpdate() {
240         if (refreshJob == null || refreshJob.isCancelled()) {
241             Configuration config = getThing().getConfiguration();
242             int refreshInterval = REFRESH_INTERVAL;
243             Object refreshConfig = config.get("pollingInterval");
244             if (refreshConfig != null) {
245                 refreshInterval = ((BigDecimal) refreshConfig).intValue();
246                 logger.debug("Setting WemoCoffeeHandler refreshInterval to '{}' seconds", refreshInterval);
247             }
248             refreshJob = scheduler.scheduleWithFixedDelay(refreshRunnable, 0, refreshInterval, TimeUnit.SECONDS);
249         }
250     }
251
252     private boolean isUpnpDeviceRegistered() {
253         return service.isRegistered(this);
254     }
255
256     @Override
257     public String getUDN() {
258         return (String) this.getThing().getConfiguration().get(UDN);
259     }
260
261     /**
262      * The {@link updateWemoState} polls the actual state of a WeMo CoffeeMaker.
263      */
264     protected void updateWemoState() {
265         String action = "GetAttributes";
266         String actionService = "deviceevent";
267
268         String soapHeader = "\"urn:Belkin:service:" + actionService + ":1#" + action + "\"";
269         String content = "<?xml version=\"1.0\"?>"
270                 + "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">"
271                 + "<s:Body>" + "<u:" + action + " xmlns:u=\"urn:Belkin:service:" + actionService + ":1\">" + "</u:"
272                 + action + ">" + "</s:Body>" + "</s:Envelope>";
273
274         try {
275             String wemoURL = getWemoURL(actionService);
276             if (wemoURL != null) {
277                 String wemoCallResponse = wemoHttpCaller.executeCall(wemoURL, soapHeader, content);
278                 if (wemoCallResponse != null) {
279                     try {
280                         String stringParser = StringUtils.substringBetween(wemoCallResponse, "<attributeList>",
281                                 "</attributeList>");
282
283                         // Due to Belkins bad response formatting, we need to run this twice.
284                         stringParser = StringEscapeUtils.unescapeXml(stringParser);
285                         stringParser = StringEscapeUtils.unescapeXml(stringParser);
286
287                         logger.trace("CoffeeMaker response '{}' for device '{}' received", stringParser,
288                                 getThing().getUID());
289
290                         stringParser = "<data>" + stringParser + "</data>";
291
292                         DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
293                         // see
294                         // https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
295                         dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
296                         dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
297                         dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
298                         dbf.setXIncludeAware(false);
299                         dbf.setExpandEntityReferences(false);
300                         DocumentBuilder db = dbf.newDocumentBuilder();
301                         InputSource is = new InputSource();
302                         is.setCharacterStream(new StringReader(stringParser));
303
304                         Document doc = db.parse(is);
305                         NodeList nodes = doc.getElementsByTagName("attribute");
306
307                         // iterate the attributes
308                         for (int i = 0; i < nodes.getLength(); i++) {
309                             Element element = (Element) nodes.item(i);
310
311                             NodeList deviceIndex = element.getElementsByTagName("name");
312                             Element line = (Element) deviceIndex.item(0);
313                             String attributeName = getCharacterDataFromElement(line);
314                             logger.trace("attributeName: {}", attributeName);
315
316                             NodeList deviceID = element.getElementsByTagName("value");
317                             line = (Element) deviceID.item(0);
318                             String attributeValue = getCharacterDataFromElement(line);
319                             logger.trace("attributeValue: {}", attributeValue);
320
321                             switch (attributeName) {
322                                 case "Mode":
323                                     State newMode = new StringType("Brewing");
324                                     switch (attributeValue) {
325                                         case "0":
326                                             updateState(CHANNEL_STATE, OnOffType.ON);
327                                             newMode = new StringType("Refill");
328                                             updateState(CHANNEL_COFFEEMODE, newMode);
329                                             break;
330                                         case "1":
331                                             updateState(CHANNEL_STATE, OnOffType.OFF);
332                                             newMode = new StringType("PlaceCarafe");
333                                             updateState(CHANNEL_COFFEEMODE, newMode);
334                                             break;
335                                         case "2":
336                                             updateState(CHANNEL_STATE, OnOffType.OFF);
337                                             newMode = new StringType("RefillWater");
338                                             updateState(CHANNEL_COFFEEMODE, newMode);
339                                             break;
340                                         case "3":
341                                             updateState(CHANNEL_STATE, OnOffType.OFF);
342                                             newMode = new StringType("Ready");
343                                             updateState(CHANNEL_COFFEEMODE, newMode);
344                                             break;
345                                         case "4":
346                                             updateState(CHANNEL_STATE, OnOffType.ON);
347                                             newMode = new StringType("Brewing");
348                                             updateState(CHANNEL_COFFEEMODE, newMode);
349                                             break;
350                                         case "5":
351                                             updateState(CHANNEL_STATE, OnOffType.OFF);
352                                             newMode = new StringType("Brewed");
353                                             updateState(CHANNEL_COFFEEMODE, newMode);
354                                             break;
355                                         case "6":
356                                             updateState(CHANNEL_STATE, OnOffType.OFF);
357                                             newMode = new StringType("CleaningBrewing");
358                                             updateState(CHANNEL_COFFEEMODE, newMode);
359                                             break;
360                                         case "7":
361                                             updateState(CHANNEL_STATE, OnOffType.OFF);
362                                             newMode = new StringType("CleaningSoaking");
363                                             updateState(CHANNEL_COFFEEMODE, newMode);
364                                             break;
365                                         case "8":
366                                             updateState(CHANNEL_STATE, OnOffType.OFF);
367                                             newMode = new StringType("BrewFailCarafeRemoved");
368                                             updateState(CHANNEL_COFFEEMODE, newMode);
369                                             break;
370                                     }
371                                     break;
372                                 case "ModeTime":
373                                     if (attributeValue != null) {
374                                         State newAttributeValue = new DecimalType(attributeValue);
375                                         updateState(CHANNEL_MODETIME, newAttributeValue);
376                                     }
377                                     break;
378                                 case "TimeRemaining":
379                                     if (attributeValue != null) {
380                                         State newAttributeValue = new DecimalType(attributeValue);
381                                         updateState(CHANNEL_TIMEREMAINING, newAttributeValue);
382                                     }
383                                     break;
384                                 case "WaterLevelReached":
385                                     if (attributeValue != null) {
386                                         State newAttributeValue = new DecimalType(attributeValue);
387                                         updateState(CHANNEL_WATERLEVELREACHED, newAttributeValue);
388                                     }
389                                     break;
390                                 case "CleanAdvise":
391                                     if (attributeValue != null) {
392                                         State newAttributeValue = attributeValue.equals("0") ? OnOffType.OFF
393                                                 : OnOffType.ON;
394                                         updateState(CHANNEL_CLEANADVISE, newAttributeValue);
395                                     }
396                                     break;
397                                 case "FilterAdvise":
398                                     if (attributeValue != null) {
399                                         State newAttributeValue = attributeValue.equals("0") ? OnOffType.OFF
400                                                 : OnOffType.ON;
401                                         updateState(CHANNEL_FILTERADVISE, newAttributeValue);
402                                     }
403                                     break;
404                                 case "Brewed":
405                                     if (attributeValue != null) {
406                                         State newAttributeValue = getDateTimeState(attributeValue);
407                                         if (newAttributeValue != null) {
408                                             updateState(CHANNEL_BREWED, newAttributeValue);
409                                         }
410                                     }
411                                     break;
412                                 case "LastCleaned":
413                                     if (attributeValue != null) {
414                                         State newAttributeValue = getDateTimeState(attributeValue);
415                                         if (newAttributeValue != null) {
416                                             updateState(CHANNEL_LASTCLEANED, newAttributeValue);
417                                         }
418                                     }
419                                     break;
420                             }
421                         }
422                     } catch (Exception e) {
423                         logger.error("Failed to parse attributeList for WeMo CoffeMaker '{}'", this.getThing().getUID(),
424                                 e);
425                     }
426                 }
427             }
428         } catch (Exception e) {
429             logger.error("Failed to get attributes for device '{}'", getThing().getUID(), e);
430         }
431     }
432
433     @SuppressWarnings("null")
434     public State getDateTimeState(String attributeValue) {
435         if (attributeValue != null) {
436             long value = 0;
437             try {
438                 value = Long.parseLong(attributeValue) * 1000; // convert s to ms
439             } catch (NumberFormatException e) {
440                 logger.error("Unable to parse attributeValue '{}' for device '{}'; expected long", attributeValue,
441                         getThing().getUID());
442                 return null;
443             }
444             ZonedDateTime zoned = ZonedDateTime.ofInstant(Instant.ofEpochMilli(value),
445                     TimeZone.getDefault().toZoneId());
446             State dateTimeState = new DateTimeType(zoned);
447             if (dateTimeState != null) {
448                 logger.trace("New attribute brewed '{}' received", dateTimeState);
449                 return dateTimeState;
450             }
451         }
452         return null;
453     }
454
455     public String getWemoURL(String actionService) {
456         URL descriptorURL = service.getDescriptorURL(this);
457         String wemoURL = null;
458         if (descriptorURL != null) {
459             String deviceURL = StringUtils.substringBefore(descriptorURL.toString(), "/setup.xml");
460             wemoURL = deviceURL + "/upnp/control/" + actionService + "1";
461             return wemoURL;
462         }
463         return null;
464     }
465
466     public static String getCharacterDataFromElement(Element e) {
467         Node child = e.getFirstChild();
468         if (child instanceof CharacterData) {
469             CharacterData cd = (CharacterData) child;
470             return cd.getData();
471         }
472         return "?";
473     }
474
475     @Override
476     public void onStatusChanged(boolean status) {
477     }
478 }