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