2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.wemo.internal.handler;
15 import static org.openhab.binding.wemo.internal.WemoBindingConstants.*;
17 import java.io.IOException;
18 import java.io.StringReader;
19 import java.math.BigDecimal;
21 import java.util.Collections;
22 import java.util.HashMap;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
28 import javax.xml.parsers.DocumentBuilder;
29 import javax.xml.parsers.DocumentBuilderFactory;
30 import javax.xml.parsers.ParserConfigurationException;
32 import org.apache.commons.lang3.StringEscapeUtils;
33 import org.apache.commons.lang3.StringUtils;
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.CharacterData;
52 import org.w3c.dom.Document;
53 import org.w3c.dom.Element;
54 import org.w3c.dom.Node;
55 import org.w3c.dom.NodeList;
56 import org.xml.sax.InputSource;
57 import org.xml.sax.SAXException;
60 * The {@link WemoHolmesHandler} is responsible for handling commands, which are
61 * sent to one of the channels and to update their states.
63 * @author Hans-Jörg Merk - Initial contribution;
66 public class WemoHolmesHandler extends AbstractWemoHandler implements UpnpIOParticipant {
68 private final Logger logger = LoggerFactory.getLogger(WemoHolmesHandler.class);
70 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_PURIFIER);
73 * The default refresh interval in Seconds.
75 private static final int DEFAULT_REFRESH_INTERVAL_SECONDS = 120;
76 private static final int FILTER_LIFE_DAYS = 330;
77 private static final int FILTER_LIFE_MINS = FILTER_LIFE_DAYS * 24 * 60;
78 private final Map<String, Boolean> subscriptionState = new HashMap<>();
79 private final Map<String, String> stateMap = Collections.synchronizedMap(new HashMap<>());
81 private UpnpIOService service;
83 private ScheduledFuture<?> refreshJob;
85 private final Runnable refreshRunnable = () -> {
86 if (!isUpnpDeviceRegistered()) {
87 logger.debug("WeMo UPnP device {} not yet registered", getUDN());
94 public WemoHolmesHandler(Thing thing, UpnpIOService upnpIOService, WemoHttpCall wemohttpCaller) {
97 this.wemoHttpCaller = wemohttpCaller;
99 logger.debug("Creating a WemoHolmesHandler for thing '{}'", getThing().getUID());
101 if (upnpIOService != null) {
102 this.service = upnpIOService;
104 logger.debug("upnpIOService not set.");
109 public void initialize() {
110 Configuration configuration = getConfig();
112 if (configuration.get("udn") != null) {
113 logger.debug("Initializing WemoHolmesHandler for UDN '{}'", configuration.get("udn"));
114 service.registerParticipant(this);
117 updateStatus(ThingStatus.ONLINE);
119 logger.debug("Cannot initalize WemoHolmesHandler. UDN not set.");
124 public void dispose() {
125 logger.debug("WemoHolmesHandler disposed.");
127 removeSubscription();
129 if (refreshJob != null && !refreshJob.isCancelled()) {
130 refreshJob.cancel(true);
136 public void handleCommand(ChannelUID channelUID, Command command) {
137 logger.trace("Command '{}' received for channel '{}'", command, channelUID);
139 String attribute = null;
142 if (command instanceof RefreshType) {
144 } else if (CHANNEL_PURIFIERMODE.equals(channelUID.getId())) {
146 String commandString = command.toString();
147 switch (commandString) {
164 } else if (CHANNEL_IONIZER.equals(channelUID.getId())) {
165 attribute = "Ionizer";
166 if (OnOffType.ON.equals(command)) {
168 } else if (OnOffType.OFF.equals(command)) {
171 } else if (CHANNEL_HUMIDIFIERMODE.equals(channelUID.getId())) {
172 attribute = "FanMode";
173 String commandString = command.toString();
174 switch (commandString) {
194 } else if (CHANNEL_DESIREDHUMIDITY.equals(channelUID.getId())) {
195 attribute = "DesiredHumidity";
196 String commandString = command.toString();
197 switch (commandString) {
214 } else if (CHANNEL_HEATERMODE.equals(channelUID.getId())) {
216 String commandString = command.toString();
217 switch (commandString) {
234 } else if (CHANNEL_TARGETTEMP.equals(channelUID.getId())) {
235 attribute = "SetTemperature";
236 value = command.toString();
239 String soapHeader = "\"urn:Belkin:service:deviceevent:1#SetAttributes\"";
240 String content = "<?xml version=\"1.0\"?>"
241 + "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">"
242 + "<s:Body>" + "<u:SetAttributes xmlns:u=\"urn:Belkin:service:deviceevent:1\">"
243 + "<attributeList><attribute><name>" + attribute + "</name><value>" + value
244 + "</value></attribute></attributeList>" + "</u:SetAttributes>" + "</s:Body>"
246 String wemoURL = getWemoURL("deviceevent");
248 if (wemoURL != null) {
249 wemoHttpCaller.executeCall(wemoURL, soapHeader, content);
251 } catch (RuntimeException e) {
252 logger.debug("Failed to send command '{}' for device '{}':", command, getThing().getUID(), e);
253 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
255 updateStatus(ThingStatus.ONLINE);
259 public void onServiceSubscribed(String service, boolean succeeded) {
260 logger.debug("WeMo {}: Subscription to service {} {}", getUDN(), service, succeeded ? "succeeded" : "failed");
261 subscriptionState.put(service, succeeded);
265 public void onValueReceived(String variable, String value, String service) {
266 logger.debug("Received pair '{}':'{}' (service '{}') for thing '{}'", variable, value, service,
267 this.getThing().getUID());
269 updateStatus(ThingStatus.ONLINE);
270 this.stateMap.put(variable, value);
273 private synchronized void onSubscription() {
274 if (service.isRegistered(this)) {
275 logger.debug("Checking WeMo GENA subscription for '{}'", this);
277 String subscription = "basicevent1";
279 if ((subscriptionState.get(subscription) == null) || !subscriptionState.get(subscription).booleanValue()) {
280 logger.debug("Setting up GENA subscription {}: Subscribing to service {}...", getUDN(), subscription);
281 service.addSubscription(this, subscription, SUBSCRIPTION_DURATION);
282 subscriptionState.put(subscription, true);
286 logger.debug("Setting up WeMo GENA subscription for '{}' FAILED - service.isRegistered(this) is FALSE",
291 private synchronized void removeSubscription() {
292 logger.debug("Removing WeMo GENA subscription for '{}'", this);
294 if (service.isRegistered(this)) {
295 String subscription = "basicevent1";
297 if ((subscriptionState.get(subscription) != null) && subscriptionState.get(subscription).booleanValue()) {
298 logger.debug("WeMo {}: Unsubscribing from service {}...", getUDN(), subscription);
299 service.removeSubscription(this, subscription);
302 subscriptionState.remove(subscription);
303 service.unregisterParticipant(this);
307 private synchronized void onUpdate() {
308 if (refreshJob == null || refreshJob.isCancelled()) {
309 Configuration config = getThing().getConfiguration();
310 int refreshInterval = DEFAULT_REFRESH_INTERVAL_SECONDS;
311 Object refreshConfig = config.get("refresh");
312 refreshInterval = refreshConfig == null ? DEFAULT_REFRESH_INTERVAL_SECONDS
313 : ((BigDecimal) refreshConfig).intValue();
314 refreshJob = scheduler.scheduleWithFixedDelay(refreshRunnable, 0, refreshInterval, TimeUnit.SECONDS);
318 private boolean isUpnpDeviceRegistered() {
319 return service.isRegistered(this);
323 public String getUDN() {
324 return (String) this.getThing().getConfiguration().get(UDN);
328 * The {@link updateWemoState} polls the actual state of a WeMo device and
329 * calls {@link onValueReceived} to update the statemap and channels..
332 protected void updateWemoState() {
333 String action = "GetAttributes";
334 String actionService = "deviceevent";
336 String soapHeader = "\"urn:Belkin:service:" + actionService + ":1#" + action + "\"";
337 String content = "<?xml version=\"1.0\"?>"
338 + "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">"
339 + "<s:Body>" + "<u:" + action + " xmlns:u=\"urn:Belkin:service:" + actionService + ":1\">" + "</u:"
340 + action + ">" + "</s:Body>" + "</s:Envelope>";
343 String wemoURL = getWemoURL(actionService);
344 if (wemoURL != null) {
345 String wemoCallResponse = wemoHttpCaller.executeCall(wemoURL, soapHeader, content);
346 if (wemoCallResponse != null) {
347 logger.trace("State response '{}' for device '{}' received", wemoCallResponse, getThing().getUID());
349 String stringParser = StringUtils.substringBetween(wemoCallResponse, "<attributeList>",
352 // Due to Belkins bad response formatting, we need to run this twice.
353 stringParser = StringEscapeUtils.unescapeXml(stringParser);
354 stringParser = StringEscapeUtils.unescapeXml(stringParser);
356 logger.trace("AirPurifier response '{}' for device '{}' received", stringParser,
357 getThing().getUID());
359 stringParser = "<data>" + stringParser + "</data>";
361 DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
363 // https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
364 dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
365 dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
366 dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
367 dbf.setXIncludeAware(false);
368 dbf.setExpandEntityReferences(false);
369 DocumentBuilder db = dbf.newDocumentBuilder();
370 InputSource is = new InputSource();
371 is.setCharacterStream(new StringReader(stringParser));
373 Document doc = db.parse(is);
374 NodeList nodes = doc.getElementsByTagName("attribute");
376 // iterate the attributes
377 for (int i = 0; i < nodes.getLength(); i++) {
378 Element element = (Element) nodes.item(i);
380 NodeList deviceIndex = element.getElementsByTagName("name");
381 Element line = (Element) deviceIndex.item(0);
382 String attributeName = getCharacterDataFromElement(line);
383 logger.trace("attributeName: {}", attributeName);
385 NodeList deviceID = element.getElementsByTagName("value");
386 line = (Element) deviceID.item(0);
387 String attributeValue = getCharacterDataFromElement(line);
388 logger.trace("attributeValue: {}", attributeValue);
390 State newMode = new StringType();
391 switch (attributeName) {
393 if ("purifier".equals(getThing().getThingTypeUID().getId())) {
394 switch (attributeValue) {
396 newMode = new StringType("OFF");
399 newMode = new StringType("LOW");
402 newMode = new StringType("MED");
405 newMode = new StringType("HIGH");
408 newMode = new StringType("AUTO");
411 updateState(CHANNEL_PURIFIERMODE, newMode);
413 switch (attributeValue) {
415 newMode = new StringType("OFF");
418 newMode = new StringType("FROSTPROTECT");
421 newMode = new StringType("HIGH");
424 newMode = new StringType("LOW");
427 newMode = new StringType("ECO");
430 updateState(CHANNEL_HEATERMODE, newMode);
434 switch (attributeValue) {
436 newMode = OnOffType.OFF;
439 newMode = OnOffType.ON;
442 updateState(CHANNEL_IONIZER, newMode);
445 switch (attributeValue) {
447 newMode = new StringType("POOR");
450 newMode = new StringType("MODERATE");
453 newMode = new StringType("GOOD");
456 updateState(CHANNEL_AIRQUALITY, newMode);
459 int filterLife = Integer.valueOf(attributeValue);
460 if ("purifier".equals(getThing().getThingTypeUID().getId())) {
461 filterLife = Math.round((filterLife / FILTER_LIFE_MINS) * 100);
463 filterLife = Math.round((filterLife / 60480) * 100);
465 updateState(CHANNEL_FILTERLIFE, new PercentType(String.valueOf(filterLife)));
467 case "ExpiredFilterTime":
468 switch (attributeValue) {
470 newMode = OnOffType.OFF;
473 newMode = OnOffType.ON;
476 updateState(CHANNEL_EXPIREDFILTERTIME, newMode);
478 case "FilterPresent":
479 switch (attributeValue) {
481 newMode = OnOffType.OFF;
484 newMode = OnOffType.ON;
487 updateState(CHANNEL_FILTERPRESENT, newMode);
490 switch (attributeValue) {
492 newMode = new StringType("OFF");
495 newMode = new StringType("LOW");
498 newMode = new StringType("MED");
501 newMode = new StringType("HIGH");
504 newMode = new StringType("AUTO");
507 updateState(CHANNEL_PURIFIERMODE, newMode);
509 case "DesiredHumidity":
510 switch (attributeValue) {
512 newMode = new PercentType("45");
515 newMode = new PercentType("50");
518 newMode = new PercentType("55");
521 newMode = new PercentType("60");
524 newMode = new PercentType("100");
527 updateState(CHANNEL_DESIREDHUMIDITY, newMode);
529 case "CurrentHumidity":
530 newMode = new StringType(attributeValue);
531 updateState(CHANNEL_CURRENTHUMIDITY, newMode);
534 newMode = new StringType(attributeValue);
535 updateState(CHANNEL_CURRENTTEMP, newMode);
537 case "SetTemperature":
538 newMode = new StringType(attributeValue);
539 updateState(CHANNEL_TARGETTEMP, newMode);
542 newMode = new StringType(attributeValue);
543 updateState(CHANNEL_AUTOOFFTIME, newMode);
545 case "TimeRemaining":
546 newMode = new StringType(attributeValue);
547 updateState(CHANNEL_HEATINGREMAINING, newMode);
553 } catch (RuntimeException | ParserConfigurationException | SAXException | IOException e) {
554 logger.debug("Failed to get actual state for device '{}':", getThing().getUID(), e);
555 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
557 updateStatus(ThingStatus.ONLINE);
560 public String getWemoURL(String actionService) {
561 URL descriptorURL = service.getDescriptorURL(this);
562 String wemoURL = null;
563 if (descriptorURL != null) {
564 String deviceURL = StringUtils.substringBefore(descriptorURL.toString(), "/setup.xml");
565 wemoURL = deviceURL + "/upnp/control/" + actionService + "1";
571 public static String getCharacterDataFromElement(Element e) {
572 Node child = e.getFirstChild();
573 if (child instanceof CharacterData) {
574 CharacterData cd = (CharacterData) child;
581 public void onStatusChanged(boolean status) {