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