]> git.basschouten.com Git - openhab-addons.git/blob
cc96df98e32bccc93b3eea6bb7acbbe2fe64344c
[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 jobLock = new Object();
72
73     private final Map<String, String> stateMap = Collections.synchronizedMap(new HashMap<>());
74
75     private @Nullable ScheduledFuture<?> pollingJob;
76
77     public WemoHolmesHandler(Thing thing, UpnpIOService upnpIOService, WemoHttpCall wemoHttpCaller) {
78         super(thing, upnpIOService, wemoHttpCaller);
79
80         logger.debug("Creating a WemoHolmesHandler for thing '{}'", getThing().getUID());
81     }
82
83     @Override
84     public void initialize() {
85         super.initialize();
86         Configuration configuration = getConfig();
87
88         if (configuration.get(UDN) != null) {
89             logger.debug("Initializing WemoHolmesHandler for UDN '{}'", configuration.get(UDN));
90             addSubscription(BASICEVENT);
91             host = getHost();
92             pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, DEFAULT_REFRESH_INTERVAL_SECONDS,
93                     TimeUnit.SECONDS);
94             updateStatus(ThingStatus.ONLINE);
95         } else {
96             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
97                     "@text/config-status.error.missing-udn");
98             logger.debug("Cannot initalize WemoHolmesHandler. UDN not set.");
99         }
100     }
101
102     @Override
103     public void dispose() {
104         logger.debug("WemoHolmesHandler disposed.");
105
106         ScheduledFuture<?> job = this.pollingJob;
107         if (job != null && !job.isCancelled()) {
108             job.cancel(true);
109         }
110         this.pollingJob = null;
111         super.dispose();
112     }
113
114     private void poll() {
115         synchronized (jobLock) {
116             if (pollingJob == null) {
117                 return;
118             }
119             try {
120                 logger.debug("Polling job");
121                 host = getHost();
122                 // Check if the Wemo device is set in the UPnP service registry
123                 // If not, set the thing state to ONLINE/CONFIG-PENDING and wait for the next poll
124                 if (!isUpnpDeviceRegistered()) {
125                     logger.debug("UPnP device {} not yet registered", getUDN());
126                     updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING,
127                             "@text/config-status.pending.device-not-registered [\"" + getUDN() + "\"]");
128                     return;
129                 }
130                 updateWemoState();
131             } catch (Exception e) {
132                 logger.debug("Exception during poll: {}", e.getMessage(), e);
133             }
134         }
135     }
136
137     @Override
138     public void handleCommand(ChannelUID channelUID, Command command) {
139         String localHost = getHost();
140         if (localHost.isEmpty()) {
141             logger.warn("Failed to send command '{}' for device '{}': IP address missing", command,
142                     getThing().getUID());
143             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
144                     "@text/config-status.error.missing-ip");
145             return;
146         }
147         String wemoURL = getWemoURL(localHost, DEVICEACTION);
148         if (wemoURL == null) {
149             logger.debug("Failed to send command '{}' for device '{}': URL cannot be created", command,
150                     getThing().getUID());
151             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
152                     "@text/config-status.error.missing-url");
153             return;
154         }
155         String attribute = null;
156         String value = null;
157
158         if (command instanceof RefreshType) {
159             updateWemoState();
160         } else if (CHANNEL_PURIFIERMODE.equals(channelUID.getId())) {
161             attribute = "Mode";
162             String commandString = command.toString();
163             switch (commandString) {
164                 case "OFF":
165                     value = "0";
166                     break;
167                 case "LOW":
168                     value = "1";
169                     break;
170                 case "MED":
171                     value = "2";
172                     break;
173                 case "HIGH":
174                     value = "3";
175                     break;
176                 case "AUTO":
177                     value = "4";
178                     break;
179             }
180         } else if (CHANNEL_IONIZER.equals(channelUID.getId())) {
181             attribute = "Ionizer";
182             if (OnOffType.ON.equals(command)) {
183                 value = "1";
184             } else if (OnOffType.OFF.equals(command)) {
185                 value = "0";
186             }
187         } else if (CHANNEL_HUMIDIFIERMODE.equals(channelUID.getId())) {
188             attribute = "FanMode";
189             String commandString = command.toString();
190             switch (commandString) {
191                 case "OFF":
192                     value = "0";
193                     break;
194                 case "MIN":
195                     value = "1";
196                     break;
197                 case "LOW":
198                     value = "2";
199                     break;
200                 case "MED":
201                     value = "3";
202                     break;
203                 case "HIGH":
204                     value = "4";
205                     break;
206                 case "MAX":
207                     value = "5";
208                     break;
209             }
210         } else if (CHANNEL_DESIREDHUMIDITY.equals(channelUID.getId())) {
211             attribute = "DesiredHumidity";
212             String commandString = command.toString();
213             switch (commandString) {
214                 case "45":
215                     value = "0";
216                     break;
217                 case "50":
218                     value = "1";
219                     break;
220                 case "55":
221                     value = "2";
222                     break;
223                 case "60":
224                     value = "3";
225                     break;
226                 case "100":
227                     value = "4";
228                     break;
229             }
230         } else if (CHANNEL_HEATERMODE.equals(channelUID.getId())) {
231             attribute = "Mode";
232             String commandString = command.toString();
233             switch (commandString) {
234                 case "OFF":
235                     value = "0";
236                     break;
237                 case "FROSTPROTECT":
238                     value = "1";
239                     break;
240                 case "HIGH":
241                     value = "2";
242                     break;
243                 case "LOW":
244                     value = "3";
245                     break;
246                 case "ECO":
247                     value = "4";
248                     break;
249             }
250         } else if (CHANNEL_TARGETTEMP.equals(channelUID.getId())) {
251             attribute = "SetTemperature";
252             value = command.toString();
253         }
254         try {
255             String soapHeader = "\"urn:Belkin:service:deviceevent:1#SetAttributes\"";
256             String content = "<?xml version=\"1.0\"?>"
257                     + "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">"
258                     + "<s:Body>" + "<u:SetAttributes xmlns:u=\"urn:Belkin:service:deviceevent:1\">"
259                     + "<attributeList>&lt;attribute&gt;&lt;name&gt;" + attribute + "&lt;/name&gt;&lt;value&gt;" + value
260                     + "&lt;/value&gt;&lt;/attribute&gt;</attributeList>" + "</u:SetAttributes>" + "</s:Body>"
261                     + "</s:Envelope>";
262             wemoHttpCaller.executeCall(wemoURL, soapHeader, content);
263             updateStatus(ThingStatus.ONLINE);
264         } catch (IOException e) {
265             logger.debug("Failed to send command '{}' for device '{}':", command, getThing().getUID(), e);
266             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
267         }
268     }
269
270     @Override
271     public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
272         logger.debug("Received pair '{}':'{}' (service '{}') for thing '{}'", variable, value, service,
273                 this.getThing().getUID());
274
275         updateStatus(ThingStatus.ONLINE);
276         if (variable != null && value != null) {
277             this.stateMap.put(variable, value);
278         }
279     }
280
281     /**
282      * The {@link updateWemoState} polls the actual state of a WeMo device and
283      * calls {@link onValueReceived} to update the statemap and channels..
284      *
285      */
286     protected void updateWemoState() {
287         String localHost = getHost();
288         if (localHost.isEmpty()) {
289             logger.warn("Failed to get actual state for device '{}': IP address missing", getThing().getUID());
290             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
291                     "@text/config-status.error.missing-ip");
292             return;
293         }
294         String actionService = DEVICEACTION;
295         String wemoURL = getWemoURL(localHost, actionService);
296         if (wemoURL == null) {
297             logger.debug("Failed to get actual state for device '{}': URL cannot be created", getThing().getUID());
298             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
299                     "@text/config-status.error.missing-url");
300             return;
301         }
302         try {
303             String action = "GetAttributes";
304             String soapHeader = "\"urn:Belkin:service:" + actionService + ":1#" + action + "\"";
305             String content = createStateRequestContent(action, actionService);
306             String wemoCallResponse = wemoHttpCaller.executeCall(wemoURL, soapHeader, content);
307             String stringParser = substringBetween(wemoCallResponse, "<attributeList>", "</attributeList>");
308
309             // Due to Belkins bad response formatting, we need to run this twice.
310             stringParser = unescapeXml(stringParser);
311             stringParser = unescapeXml(stringParser);
312
313             logger.trace("AirPurifier response '{}' for device '{}' received", stringParser, getThing().getUID());
314
315             stringParser = "<data>" + stringParser + "</data>";
316
317             DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
318             // see
319             // https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
320             dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
321             dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
322             dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
323             dbf.setXIncludeAware(false);
324             dbf.setExpandEntityReferences(false);
325             DocumentBuilder db = dbf.newDocumentBuilder();
326             InputSource is = new InputSource();
327             is.setCharacterStream(new StringReader(stringParser));
328
329             Document doc = db.parse(is);
330             NodeList nodes = doc.getElementsByTagName("attribute");
331
332             // iterate the attributes
333             for (int i = 0; i < nodes.getLength(); i++) {
334                 Element element = (Element) nodes.item(i);
335
336                 NodeList deviceIndex = element.getElementsByTagName("name");
337                 Element line = (Element) deviceIndex.item(0);
338                 String attributeName = getCharacterDataFromElement(line);
339                 logger.trace("attributeName: {}", attributeName);
340
341                 NodeList deviceID = element.getElementsByTagName("value");
342                 line = (Element) deviceID.item(0);
343                 String attributeValue = getCharacterDataFromElement(line);
344                 logger.trace("attributeValue: {}", attributeValue);
345
346                 State newMode = new StringType();
347                 switch (attributeName) {
348                     case "Mode":
349                         if ("purifier".equals(getThing().getThingTypeUID().getId())) {
350                             switch (attributeValue) {
351                                 case "0":
352                                     newMode = new StringType("OFF");
353                                     break;
354                                 case "1":
355                                     newMode = new StringType("LOW");
356                                     break;
357                                 case "2":
358                                     newMode = new StringType("MED");
359                                     break;
360                                 case "3":
361                                     newMode = new StringType("HIGH");
362                                     break;
363                                 case "4":
364                                     newMode = new StringType("AUTO");
365                                     break;
366                             }
367                             updateState(CHANNEL_PURIFIERMODE, newMode);
368                         } else {
369                             switch (attributeValue) {
370                                 case "0":
371                                     newMode = new StringType("OFF");
372                                     break;
373                                 case "1":
374                                     newMode = new StringType("FROSTPROTECT");
375                                     break;
376                                 case "2":
377                                     newMode = new StringType("HIGH");
378                                     break;
379                                 case "3":
380                                     newMode = new StringType("LOW");
381                                     break;
382                                 case "4":
383                                     newMode = new StringType("ECO");
384                                     break;
385                             }
386                             updateState(CHANNEL_HEATERMODE, newMode);
387                         }
388                         break;
389                     case "Ionizer":
390                         switch (attributeValue) {
391                             case "0":
392                                 newMode = OnOffType.OFF;
393                                 break;
394                             case "1":
395                                 newMode = OnOffType.ON;
396                                 break;
397                         }
398                         updateState(CHANNEL_IONIZER, newMode);
399                         break;
400                     case "AirQuality":
401                         switch (attributeValue) {
402                             case "0":
403                                 newMode = new StringType("POOR");
404                                 break;
405                             case "1":
406                                 newMode = new StringType("MODERATE");
407                                 break;
408                             case "2":
409                                 newMode = new StringType("GOOD");
410                                 break;
411                         }
412                         updateState(CHANNEL_AIRQUALITY, newMode);
413                         break;
414                     case "FilterLife":
415                         int filterLife = Integer.valueOf(attributeValue);
416                         if ("purifier".equals(getThing().getThingTypeUID().getId())) {
417                             filterLife = Math.round((filterLife / FILTER_LIFE_MINS) * 100);
418                         } else {
419                             filterLife = Math.round((filterLife / 60480) * 100);
420                         }
421                         updateState(CHANNEL_FILTERLIFE, new PercentType(String.valueOf(filterLife)));
422                         break;
423                     case "ExpiredFilterTime":
424                         switch (attributeValue) {
425                             case "0":
426                                 newMode = OnOffType.OFF;
427                                 break;
428                             case "1":
429                                 newMode = OnOffType.ON;
430                                 break;
431                         }
432                         updateState(CHANNEL_EXPIREDFILTERTIME, newMode);
433                         break;
434                     case "FilterPresent":
435                         switch (attributeValue) {
436                             case "0":
437                                 newMode = OnOffType.OFF;
438                                 break;
439                             case "1":
440                                 newMode = OnOffType.ON;
441                                 break;
442                         }
443                         updateState(CHANNEL_FILTERPRESENT, newMode);
444                         break;
445                     case "FANMode":
446                         switch (attributeValue) {
447                             case "0":
448                                 newMode = new StringType("OFF");
449                                 break;
450                             case "1":
451                                 newMode = new StringType("LOW");
452                                 break;
453                             case "2":
454                                 newMode = new StringType("MED");
455                                 break;
456                             case "3":
457                                 newMode = new StringType("HIGH");
458                                 break;
459                             case "4":
460                                 newMode = new StringType("AUTO");
461                                 break;
462                         }
463                         updateState(CHANNEL_PURIFIERMODE, newMode);
464                         break;
465                     case "DesiredHumidity":
466                         switch (attributeValue) {
467                             case "0":
468                                 newMode = new PercentType("45");
469                                 break;
470                             case "1":
471                                 newMode = new PercentType("50");
472                                 break;
473                             case "2":
474                                 newMode = new PercentType("55");
475                                 break;
476                             case "3":
477                                 newMode = new PercentType("60");
478                                 break;
479                             case "4":
480                                 newMode = new PercentType("100");
481                                 break;
482                         }
483                         updateState(CHANNEL_DESIREDHUMIDITY, newMode);
484                         break;
485                     case "CurrentHumidity":
486                         newMode = new StringType(attributeValue);
487                         updateState(CHANNEL_CURRENTHUMIDITY, newMode);
488                         break;
489                     case "Temperature":
490                         newMode = new StringType(attributeValue);
491                         updateState(CHANNEL_CURRENTTEMP, newMode);
492                         break;
493                     case "SetTemperature":
494                         newMode = new StringType(attributeValue);
495                         updateState(CHANNEL_TARGETTEMP, newMode);
496                         break;
497                     case "AutoOffTime":
498                         newMode = new StringType(attributeValue);
499                         updateState(CHANNEL_AUTOOFFTIME, newMode);
500                         break;
501                     case "TimeRemaining":
502                         newMode = new StringType(attributeValue);
503                         updateState(CHANNEL_HEATINGREMAINING, newMode);
504                         break;
505                 }
506             }
507             updateStatus(ThingStatus.ONLINE);
508         } catch (RuntimeException | ParserConfigurationException | SAXException | IOException e) {
509             logger.debug("Failed to get actual state for device '{}':", getThing().getUID(), e);
510             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
511         }
512     }
513 }