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