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