]> git.basschouten.com Git - openhab-addons.git/blob
a97a99f41bf56ac88530069e06b5fcf4372541a1
[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.IOException;
19 import java.io.StringReader;
20 import java.net.URL;
21 import java.util.Collections;
22 import java.util.HashMap;
23 import java.util.Map;
24 import java.util.Set;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
27
28 import javax.xml.parsers.DocumentBuilder;
29 import javax.xml.parsers.DocumentBuilderFactory;
30 import javax.xml.parsers.ParserConfigurationException;
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.UpnpIOParticipant;
37 import org.openhab.core.io.transport.upnp.UpnpIOService;
38 import org.openhab.core.library.types.OnOffType;
39 import org.openhab.core.library.types.PercentType;
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 import org.xml.sax.SAXException;
56
57 /**
58  * The {@link WemoHolmesHandler} is responsible for handling commands, which are
59  * sent to one of the channels and to update their states.
60  *
61  * @author Hans-Jörg Merk - Initial contribution;
62  */
63 @NonNullByDefault
64 public class WemoHolmesHandler extends AbstractWemoHandler implements UpnpIOParticipant {
65
66     private final Logger logger = LoggerFactory.getLogger(WemoHolmesHandler.class);
67
68     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_PURIFIER);
69
70     private static final int FILTER_LIFE_DAYS = 330;
71     private static final int FILTER_LIFE_MINS = FILTER_LIFE_DAYS * 24 * 60;
72
73     private final Object upnpLock = new Object();
74     private final Object jobLock = new Object();
75
76     private final Map<String, String> stateMap = Collections.synchronizedMap(new HashMap<>());
77
78     private @Nullable UpnpIOService service;
79
80     private WemoHttpCall wemoCall;
81
82     private String host = "";
83
84     private Map<String, Boolean> subscriptionState = new HashMap<>();
85
86     private @Nullable ScheduledFuture<?> pollingJob;
87
88     public WemoHolmesHandler(Thing thing, UpnpIOService upnpIOService, WemoHttpCall wemoHttpCaller) {
89         super(thing, wemoHttpCaller);
90
91         this.service = upnpIOService;
92         this.wemoCall = wemoHttpCaller;
93
94         logger.debug("Creating a WemoHolmesHandler for thing '{}'", getThing().getUID());
95     }
96
97     @Override
98     public void initialize() {
99         Configuration configuration = getConfig();
100
101         if (configuration.get(UDN) != null) {
102             logger.debug("Initializing WemoHolmesHandler for UDN '{}'", configuration.get(UDN));
103             UpnpIOService localService = service;
104             if (localService != null) {
105                 localService.registerParticipant(this);
106             }
107             host = getHost();
108             pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, DEFAULT_REFRESH_INTERVALL_SECONDS,
109                     TimeUnit.SECONDS);
110             updateStatus(ThingStatus.ONLINE);
111         } else {
112             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
113                     "@text/config-status.error.missing-udn");
114             logger.debug("Cannot initalize WemoHolmesHandler. UDN not set.");
115         }
116     }
117
118     @Override
119     public void dispose() {
120         logger.debug("WemoHolmesHandler disposed.");
121
122         ScheduledFuture<?> job = this.pollingJob;
123         if (job != null && !job.isCancelled()) {
124             job.cancel(true);
125         }
126         this.pollingJob = null;
127         removeSubscription();
128     }
129
130     private void poll() {
131         synchronized (jobLock) {
132             if (pollingJob == null) {
133                 return;
134             }
135             try {
136                 logger.debug("Polling job");
137                 host = getHost();
138                 // Check if the Wemo device is set in the UPnP service registry
139                 // If not, set the thing state to ONLINE/CONFIG-PENDING and wait for the next poll
140                 if (!isUpnpDeviceRegistered()) {
141                     logger.debug("UPnP device {} not yet registered", getUDN());
142                     updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING,
143                             "@text/config-status.pending.device-not-registered [\"" + getUDN() + "\"]");
144                     synchronized (upnpLock) {
145                         subscriptionState = new HashMap<>();
146                     }
147                     return;
148                 }
149                 updateStatus(ThingStatus.ONLINE);
150                 updateWemoState();
151                 addSubscription();
152             } catch (Exception e) {
153                 logger.debug("Exception during poll: {}", e.getMessage(), e);
154             }
155         }
156     }
157
158     @Override
159     public void handleCommand(ChannelUID channelUID, Command command) {
160         String localHost = getHost();
161         if (localHost.isEmpty()) {
162             logger.error("Failed to send command '{}' for device '{}': IP address missing", command,
163                     getThing().getUID());
164             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
165                     "@text/config-status.error.missing-ip");
166             return;
167         }
168         String wemoURL = getWemoURL(localHost, DEVICEACTION);
169         if (wemoURL == null) {
170             logger.error("Failed to send command '{}' for device '{}': URL cannot be created", command,
171                     getThing().getUID());
172             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
173                     "@text/config-status.error.missing-url");
174             return;
175         }
176         String attribute = null;
177         String value = null;
178
179         if (command instanceof RefreshType) {
180             updateWemoState();
181         } else if (CHANNEL_PURIFIERMODE.equals(channelUID.getId())) {
182             attribute = "Mode";
183             String commandString = command.toString();
184             switch (commandString) {
185                 case "OFF":
186                     value = "0";
187                     break;
188                 case "LOW":
189                     value = "1";
190                     break;
191                 case "MED":
192                     value = "2";
193                     break;
194                 case "HIGH":
195                     value = "3";
196                     break;
197                 case "AUTO":
198                     value = "4";
199                     break;
200             }
201         } else if (CHANNEL_IONIZER.equals(channelUID.getId())) {
202             attribute = "Ionizer";
203             if (OnOffType.ON.equals(command)) {
204                 value = "1";
205             } else if (OnOffType.OFF.equals(command)) {
206                 value = "0";
207             }
208         } else if (CHANNEL_HUMIDIFIERMODE.equals(channelUID.getId())) {
209             attribute = "FanMode";
210             String commandString = command.toString();
211             switch (commandString) {
212                 case "OFF":
213                     value = "0";
214                     break;
215                 case "MIN":
216                     value = "1";
217                     break;
218                 case "LOW":
219                     value = "2";
220                     break;
221                 case "MED":
222                     value = "3";
223                     break;
224                 case "HIGH":
225                     value = "4";
226                     break;
227                 case "MAX":
228                     value = "5";
229                     break;
230             }
231         } else if (CHANNEL_DESIREDHUMIDITY.equals(channelUID.getId())) {
232             attribute = "DesiredHumidity";
233             String commandString = command.toString();
234             switch (commandString) {
235                 case "45":
236                     value = "0";
237                     break;
238                 case "50":
239                     value = "1";
240                     break;
241                 case "55":
242                     value = "2";
243                     break;
244                 case "60":
245                     value = "3";
246                     break;
247                 case "100":
248                     value = "4";
249                     break;
250             }
251         } else if (CHANNEL_HEATERMODE.equals(channelUID.getId())) {
252             attribute = "Mode";
253             String commandString = command.toString();
254             switch (commandString) {
255                 case "OFF":
256                     value = "0";
257                     break;
258                 case "FROSTPROTECT":
259                     value = "1";
260                     break;
261                 case "HIGH":
262                     value = "2";
263                     break;
264                 case "LOW":
265                     value = "3";
266                     break;
267                 case "ECO":
268                     value = "4";
269                     break;
270             }
271         } else if (CHANNEL_TARGETTEMP.equals(channelUID.getId())) {
272             attribute = "SetTemperature";
273             value = command.toString();
274         }
275         try {
276             String soapHeader = "\"urn:Belkin:service:deviceevent:1#SetAttributes\"";
277             String content = "<?xml version=\"1.0\"?>"
278                     + "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">"
279                     + "<s:Body>" + "<u:SetAttributes xmlns:u=\"urn:Belkin:service:deviceevent:1\">"
280                     + "<attributeList>&lt;attribute&gt;&lt;name&gt;" + attribute + "&lt;/name&gt;&lt;value&gt;" + value
281                     + "&lt;/value&gt;&lt;/attribute&gt;</attributeList>" + "</u:SetAttributes>" + "</s:Body>"
282                     + "</s:Envelope>";
283             String wemoCallResponse = wemoCall.executeCall(wemoURL, soapHeader, content);
284             if (wemoCallResponse != null && logger.isTraceEnabled()) {
285                 logger.trace("wemoCall to URL '{}' for device '{}'", wemoURL, getThing().getUID());
286                 logger.trace("wemoCall with soapHeader '{}' for device '{}'", soapHeader, getThing().getUID());
287                 logger.trace("wemoCall with content '{}' for device '{}'", content, getThing().getUID());
288                 logger.trace("wemoCall with response '{}' for device '{}'", wemoCallResponse, getThing().getUID());
289             }
290         } catch (RuntimeException e) {
291             logger.debug("Failed to send command '{}' for device '{}':", command, getThing().getUID(), e);
292             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
293         }
294         updateStatus(ThingStatus.ONLINE);
295     }
296
297     @Override
298     public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
299         if (service != null) {
300             logger.debug("WeMo {}: Subscription to service {} {}", getUDN(), service,
301                     succeeded ? "succeeded" : "failed");
302             subscriptionState.put(service, succeeded);
303         }
304     }
305
306     @Override
307     public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
308         logger.debug("Received pair '{}':'{}' (service '{}') for thing '{}'", variable, value, service,
309                 this.getThing().getUID());
310
311         updateStatus(ThingStatus.ONLINE);
312         if (variable != null && value != null) {
313             this.stateMap.put(variable, value);
314         }
315     }
316
317     private synchronized void addSubscription() {
318         synchronized (upnpLock) {
319             UpnpIOService localService = service;
320             if (localService != null) {
321                 if (localService.isRegistered(this)) {
322                     logger.debug("Checking WeMo GENA subscription for '{}'", getThing().getUID());
323
324                     String subscription = BASICEVENT;
325
326                     if (subscriptionState.get(subscription) == null) {
327                         logger.debug("Setting up GENA subscription {}: Subscribing to service {}...", getUDN(),
328                                 subscription);
329                         localService.addSubscription(this, subscription, SUBSCRIPTION_DURATION_SECONDS);
330                         subscriptionState.put(subscription, true);
331                     }
332                 } else {
333                     logger.debug(
334                             "Setting up WeMo GENA subscription for '{}' FAILED - service.isRegistered(this) is FALSE",
335                             getThing().getUID());
336                 }
337             }
338         }
339     }
340
341     private synchronized void removeSubscription() {
342         synchronized (upnpLock) {
343             UpnpIOService localService = service;
344             if (localService != null) {
345                 if (localService.isRegistered(this)) {
346                     logger.debug("Removing WeMo GENA subscription for '{}'", getThing().getUID());
347                     String subscription = BASICEVENT;
348
349                     if (subscriptionState.get(subscription) != null) {
350                         logger.debug("WeMo {}: Unsubscribing from service {}...", getUDN(), subscription);
351                         localService.removeSubscription(this, subscription);
352                     }
353                     subscriptionState.remove(subscription);
354                     localService.unregisterParticipant(this);
355                 }
356             }
357         }
358     }
359
360     private boolean isUpnpDeviceRegistered() {
361         UpnpIOService localService = service;
362         if (localService != null) {
363             return localService.isRegistered(this);
364         }
365         return false;
366     }
367
368     @Override
369     public String getUDN() {
370         return (String) this.getThing().getConfiguration().get(UDN);
371     }
372
373     /**
374      * The {@link updateWemoState} polls the actual state of a WeMo device and
375      * calls {@link onValueReceived} to update the statemap and channels..
376      *
377      */
378     protected void updateWemoState() {
379         String localHost = getHost();
380         if (localHost.isEmpty()) {
381             logger.error("Failed to get actual state for device '{}': IP address missing", getThing().getUID());
382             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
383                     "@text/config-status.error.missing-ip");
384             return;
385         }
386         String actionService = DEVICEACTION;
387         String wemoURL = getWemoURL(localHost, actionService);
388         if (wemoURL == null) {
389             logger.error("Failed to get actual state for device '{}': URL cannot be created", getThing().getUID());
390             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
391                     "@text/config-status.error.missing-url");
392             return;
393         }
394         try {
395             String action = "GetAttributes";
396             String soapHeader = "\"urn:Belkin:service:" + actionService + ":1#" + action + "\"";
397             String content = createStateRequestContent(action, actionService);
398             String wemoCallResponse = wemoCall.executeCall(wemoURL, soapHeader, content);
399             if (wemoCallResponse != null) {
400                 if (logger.isTraceEnabled()) {
401                     logger.trace("wemoCall to URL '{}' for device '{}'", wemoURL, getThing().getUID());
402                     logger.trace("wemoCall with soapHeader '{}' for device '{}'", soapHeader, getThing().getUID());
403                     logger.trace("wemoCall with content '{}' for device '{}'", content, getThing().getUID());
404                     logger.trace("wemoCall with response '{}' for device '{}'", wemoCallResponse, getThing().getUID());
405                 }
406
407                 String stringParser = substringBetween(wemoCallResponse, "<attributeList>", "</attributeList>");
408
409                 // Due to Belkins bad response formatting, we need to run this twice.
410                 stringParser = unescapeXml(stringParser);
411                 stringParser = unescapeXml(stringParser);
412
413                 logger.trace("AirPurifier response '{}' for device '{}' received", stringParser, getThing().getUID());
414
415                 stringParser = "<data>" + stringParser + "</data>";
416
417                 DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
418                 // see
419                 // https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
420                 dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
421                 dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
422                 dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
423                 dbf.setXIncludeAware(false);
424                 dbf.setExpandEntityReferences(false);
425                 DocumentBuilder db = dbf.newDocumentBuilder();
426                 InputSource is = new InputSource();
427                 is.setCharacterStream(new StringReader(stringParser));
428
429                 Document doc = db.parse(is);
430                 NodeList nodes = doc.getElementsByTagName("attribute");
431
432                 // iterate the attributes
433                 for (int i = 0; i < nodes.getLength(); i++) {
434                     Element element = (Element) nodes.item(i);
435
436                     NodeList deviceIndex = element.getElementsByTagName("name");
437                     Element line = (Element) deviceIndex.item(0);
438                     String attributeName = getCharacterDataFromElement(line);
439                     logger.trace("attributeName: {}", attributeName);
440
441                     NodeList deviceID = element.getElementsByTagName("value");
442                     line = (Element) deviceID.item(0);
443                     String attributeValue = getCharacterDataFromElement(line);
444                     logger.trace("attributeValue: {}", attributeValue);
445
446                     State newMode = new StringType();
447                     switch (attributeName) {
448                         case "Mode":
449                             if ("purifier".equals(getThing().getThingTypeUID().getId())) {
450                                 switch (attributeValue) {
451                                     case "0":
452                                         newMode = new StringType("OFF");
453                                         break;
454                                     case "1":
455                                         newMode = new StringType("LOW");
456                                         break;
457                                     case "2":
458                                         newMode = new StringType("MED");
459                                         break;
460                                     case "3":
461                                         newMode = new StringType("HIGH");
462                                         break;
463                                     case "4":
464                                         newMode = new StringType("AUTO");
465                                         break;
466                                 }
467                                 updateState(CHANNEL_PURIFIERMODE, newMode);
468                             } else {
469                                 switch (attributeValue) {
470                                     case "0":
471                                         newMode = new StringType("OFF");
472                                         break;
473                                     case "1":
474                                         newMode = new StringType("FROSTPROTECT");
475                                         break;
476                                     case "2":
477                                         newMode = new StringType("HIGH");
478                                         break;
479                                     case "3":
480                                         newMode = new StringType("LOW");
481                                         break;
482                                     case "4":
483                                         newMode = new StringType("ECO");
484                                         break;
485                                 }
486                                 updateState(CHANNEL_HEATERMODE, newMode);
487                             }
488                             break;
489                         case "Ionizer":
490                             switch (attributeValue) {
491                                 case "0":
492                                     newMode = OnOffType.OFF;
493                                     break;
494                                 case "1":
495                                     newMode = OnOffType.ON;
496                                     break;
497                             }
498                             updateState(CHANNEL_IONIZER, newMode);
499                             break;
500                         case "AirQuality":
501                             switch (attributeValue) {
502                                 case "0":
503                                     newMode = new StringType("POOR");
504                                     break;
505                                 case "1":
506                                     newMode = new StringType("MODERATE");
507                                     break;
508                                 case "2":
509                                     newMode = new StringType("GOOD");
510                                     break;
511                             }
512                             updateState(CHANNEL_AIRQUALITY, newMode);
513                             break;
514                         case "FilterLife":
515                             int filterLife = Integer.valueOf(attributeValue);
516                             if ("purifier".equals(getThing().getThingTypeUID().getId())) {
517                                 filterLife = Math.round((filterLife / FILTER_LIFE_MINS) * 100);
518                             } else {
519                                 filterLife = Math.round((filterLife / 60480) * 100);
520                             }
521                             updateState(CHANNEL_FILTERLIFE, new PercentType(String.valueOf(filterLife)));
522                             break;
523                         case "ExpiredFilterTime":
524                             switch (attributeValue) {
525                                 case "0":
526                                     newMode = OnOffType.OFF;
527                                     break;
528                                 case "1":
529                                     newMode = OnOffType.ON;
530                                     break;
531                             }
532                             updateState(CHANNEL_EXPIREDFILTERTIME, newMode);
533                             break;
534                         case "FilterPresent":
535                             switch (attributeValue) {
536                                 case "0":
537                                     newMode = OnOffType.OFF;
538                                     break;
539                                 case "1":
540                                     newMode = OnOffType.ON;
541                                     break;
542                             }
543                             updateState(CHANNEL_FILTERPRESENT, newMode);
544                             break;
545                         case "FANMode":
546                             switch (attributeValue) {
547                                 case "0":
548                                     newMode = new StringType("OFF");
549                                     break;
550                                 case "1":
551                                     newMode = new StringType("LOW");
552                                     break;
553                                 case "2":
554                                     newMode = new StringType("MED");
555                                     break;
556                                 case "3":
557                                     newMode = new StringType("HIGH");
558                                     break;
559                                 case "4":
560                                     newMode = new StringType("AUTO");
561                                     break;
562                             }
563                             updateState(CHANNEL_PURIFIERMODE, newMode);
564                             break;
565                         case "DesiredHumidity":
566                             switch (attributeValue) {
567                                 case "0":
568                                     newMode = new PercentType("45");
569                                     break;
570                                 case "1":
571                                     newMode = new PercentType("50");
572                                     break;
573                                 case "2":
574                                     newMode = new PercentType("55");
575                                     break;
576                                 case "3":
577                                     newMode = new PercentType("60");
578                                     break;
579                                 case "4":
580                                     newMode = new PercentType("100");
581                                     break;
582                             }
583                             updateState(CHANNEL_DESIREDHUMIDITY, newMode);
584                             break;
585                         case "CurrentHumidity":
586                             newMode = new StringType(attributeValue);
587                             updateState(CHANNEL_CURRENTHUMIDITY, newMode);
588                             break;
589                         case "Temperature":
590                             newMode = new StringType(attributeValue);
591                             updateState(CHANNEL_CURRENTTEMP, newMode);
592                             break;
593                         case "SetTemperature":
594                             newMode = new StringType(attributeValue);
595                             updateState(CHANNEL_TARGETTEMP, newMode);
596                             break;
597                         case "AutoOffTime":
598                             newMode = new StringType(attributeValue);
599                             updateState(CHANNEL_AUTOOFFTIME, newMode);
600                             break;
601                         case "TimeRemaining":
602                             newMode = new StringType(attributeValue);
603                             updateState(CHANNEL_HEATINGREMAINING, newMode);
604                             break;
605                     }
606                 }
607             }
608         } catch (RuntimeException | ParserConfigurationException | SAXException | IOException e) {
609             logger.debug("Failed to get actual state for device '{}':", getThing().getUID(), e);
610             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
611         }
612         updateStatus(ThingStatus.ONLINE);
613     }
614
615     public String getHost() {
616         String localHost = host;
617         if (!localHost.isEmpty()) {
618             return localHost;
619         }
620         UpnpIOService localService = service;
621         if (localService != null) {
622             URL descriptorURL = localService.getDescriptorURL(this);
623             if (descriptorURL != null) {
624                 return descriptorURL.getHost();
625             }
626         }
627         return "";
628     }
629
630     @Override
631     public void onStatusChanged(boolean status) {
632     }
633 }