2 * Copyright (c) 2010-2022 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.*;
16 import static org.openhab.binding.wemo.internal.WemoUtil.*;
18 import java.io.IOException;
19 import java.io.StringReader;
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.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;
58 * The {@link WemoHolmesHandler} is responsible for handling commands, which are
59 * sent to one of the channels and to update their states.
61 * @author Hans-Jörg Merk - Initial contribution;
64 public class WemoHolmesHandler extends AbstractWemoHandler implements UpnpIOParticipant {
66 private final Logger logger = LoggerFactory.getLogger(WemoHolmesHandler.class);
68 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_PURIFIER);
70 private static final int FILTER_LIFE_DAYS = 330;
71 private static final int FILTER_LIFE_MINS = FILTER_LIFE_DAYS * 24 * 60;
73 private final Object upnpLock = new Object();
74 private final Object jobLock = new Object();
76 private final Map<String, String> stateMap = Collections.synchronizedMap(new HashMap<>());
78 private @Nullable UpnpIOService service;
80 private WemoHttpCall wemoCall;
82 private String host = "";
84 private Map<String, Boolean> subscriptionState = new HashMap<>();
86 private @Nullable ScheduledFuture<?> pollingJob;
88 public WemoHolmesHandler(Thing thing, UpnpIOService upnpIOService, WemoHttpCall wemoHttpCaller) {
89 super(thing, wemoHttpCaller);
91 this.service = upnpIOService;
92 this.wemoCall = wemoHttpCaller;
94 logger.debug("Creating a WemoHolmesHandler for thing '{}'", getThing().getUID());
98 public void initialize() {
99 Configuration configuration = getConfig();
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);
108 pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, DEFAULT_REFRESH_INTERVALL_SECONDS,
110 updateStatus(ThingStatus.ONLINE);
112 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
113 "@text/config-status.error.missing-udn");
114 logger.debug("Cannot initalize WemoHolmesHandler. UDN not set.");
119 public void dispose() {
120 logger.debug("WemoHolmesHandler disposed.");
122 ScheduledFuture<?> job = this.pollingJob;
123 if (job != null && !job.isCancelled()) {
126 this.pollingJob = null;
127 removeSubscription();
130 private void poll() {
131 synchronized (jobLock) {
132 if (pollingJob == null) {
136 logger.debug("Polling job");
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<>();
149 updateStatus(ThingStatus.ONLINE);
152 } catch (Exception e) {
153 logger.debug("Exception during poll: {}", e.getMessage(), e);
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");
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");
176 String attribute = null;
179 if (command instanceof RefreshType) {
181 } else if (CHANNEL_PURIFIERMODE.equals(channelUID.getId())) {
183 String commandString = command.toString();
184 switch (commandString) {
201 } else if (CHANNEL_IONIZER.equals(channelUID.getId())) {
202 attribute = "Ionizer";
203 if (OnOffType.ON.equals(command)) {
205 } else if (OnOffType.OFF.equals(command)) {
208 } else if (CHANNEL_HUMIDIFIERMODE.equals(channelUID.getId())) {
209 attribute = "FanMode";
210 String commandString = command.toString();
211 switch (commandString) {
231 } else if (CHANNEL_DESIREDHUMIDITY.equals(channelUID.getId())) {
232 attribute = "DesiredHumidity";
233 String commandString = command.toString();
234 switch (commandString) {
251 } else if (CHANNEL_HEATERMODE.equals(channelUID.getId())) {
253 String commandString = command.toString();
254 switch (commandString) {
271 } else if (CHANNEL_TARGETTEMP.equals(channelUID.getId())) {
272 attribute = "SetTemperature";
273 value = command.toString();
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><attribute><name>" + attribute + "</name><value>" + value
281 + "</value></attribute></attributeList>" + "</u:SetAttributes>" + "</s:Body>"
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());
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());
294 updateStatus(ThingStatus.ONLINE);
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);
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());
311 updateStatus(ThingStatus.ONLINE);
312 if (variable != null && value != null) {
313 this.stateMap.put(variable, value);
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());
324 String subscription = BASICEVENT;
326 if (subscriptionState.get(subscription) == null) {
327 logger.debug("Setting up GENA subscription {}: Subscribing to service {}...", getUDN(),
329 localService.addSubscription(this, subscription, SUBSCRIPTION_DURATION_SECONDS);
330 subscriptionState.put(subscription, true);
334 "Setting up WeMo GENA subscription for '{}' FAILED - service.isRegistered(this) is FALSE",
335 getThing().getUID());
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;
349 if (subscriptionState.get(subscription) != null) {
350 logger.debug("WeMo {}: Unsubscribing from service {}...", getUDN(), subscription);
351 localService.removeSubscription(this, subscription);
353 subscriptionState.remove(subscription);
354 localService.unregisterParticipant(this);
360 private boolean isUpnpDeviceRegistered() {
361 UpnpIOService localService = service;
362 if (localService != null) {
363 return localService.isRegistered(this);
369 public String getUDN() {
370 return (String) this.getThing().getConfiguration().get(UDN);
374 * The {@link updateWemoState} polls the actual state of a WeMo device and
375 * calls {@link onValueReceived} to update the statemap and channels..
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");
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");
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());
407 String stringParser = substringBetween(wemoCallResponse, "<attributeList>", "</attributeList>");
409 // Due to Belkins bad response formatting, we need to run this twice.
410 stringParser = unescapeXml(stringParser);
411 stringParser = unescapeXml(stringParser);
413 logger.trace("AirPurifier response '{}' for device '{}' received", stringParser, getThing().getUID());
415 stringParser = "<data>" + stringParser + "</data>";
417 DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
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));
429 Document doc = db.parse(is);
430 NodeList nodes = doc.getElementsByTagName("attribute");
432 // iterate the attributes
433 for (int i = 0; i < nodes.getLength(); i++) {
434 Element element = (Element) nodes.item(i);
436 NodeList deviceIndex = element.getElementsByTagName("name");
437 Element line = (Element) deviceIndex.item(0);
438 String attributeName = getCharacterDataFromElement(line);
439 logger.trace("attributeName: {}", attributeName);
441 NodeList deviceID = element.getElementsByTagName("value");
442 line = (Element) deviceID.item(0);
443 String attributeValue = getCharacterDataFromElement(line);
444 logger.trace("attributeValue: {}", attributeValue);
446 State newMode = new StringType();
447 switch (attributeName) {
449 if ("purifier".equals(getThing().getThingTypeUID().getId())) {
450 switch (attributeValue) {
452 newMode = new StringType("OFF");
455 newMode = new StringType("LOW");
458 newMode = new StringType("MED");
461 newMode = new StringType("HIGH");
464 newMode = new StringType("AUTO");
467 updateState(CHANNEL_PURIFIERMODE, newMode);
469 switch (attributeValue) {
471 newMode = new StringType("OFF");
474 newMode = new StringType("FROSTPROTECT");
477 newMode = new StringType("HIGH");
480 newMode = new StringType("LOW");
483 newMode = new StringType("ECO");
486 updateState(CHANNEL_HEATERMODE, newMode);
490 switch (attributeValue) {
492 newMode = OnOffType.OFF;
495 newMode = OnOffType.ON;
498 updateState(CHANNEL_IONIZER, newMode);
501 switch (attributeValue) {
503 newMode = new StringType("POOR");
506 newMode = new StringType("MODERATE");
509 newMode = new StringType("GOOD");
512 updateState(CHANNEL_AIRQUALITY, newMode);
515 int filterLife = Integer.valueOf(attributeValue);
516 if ("purifier".equals(getThing().getThingTypeUID().getId())) {
517 filterLife = Math.round((filterLife / FILTER_LIFE_MINS) * 100);
519 filterLife = Math.round((filterLife / 60480) * 100);
521 updateState(CHANNEL_FILTERLIFE, new PercentType(String.valueOf(filterLife)));
523 case "ExpiredFilterTime":
524 switch (attributeValue) {
526 newMode = OnOffType.OFF;
529 newMode = OnOffType.ON;
532 updateState(CHANNEL_EXPIREDFILTERTIME, newMode);
534 case "FilterPresent":
535 switch (attributeValue) {
537 newMode = OnOffType.OFF;
540 newMode = OnOffType.ON;
543 updateState(CHANNEL_FILTERPRESENT, newMode);
546 switch (attributeValue) {
548 newMode = new StringType("OFF");
551 newMode = new StringType("LOW");
554 newMode = new StringType("MED");
557 newMode = new StringType("HIGH");
560 newMode = new StringType("AUTO");
563 updateState(CHANNEL_PURIFIERMODE, newMode);
565 case "DesiredHumidity":
566 switch (attributeValue) {
568 newMode = new PercentType("45");
571 newMode = new PercentType("50");
574 newMode = new PercentType("55");
577 newMode = new PercentType("60");
580 newMode = new PercentType("100");
583 updateState(CHANNEL_DESIREDHUMIDITY, newMode);
585 case "CurrentHumidity":
586 newMode = new StringType(attributeValue);
587 updateState(CHANNEL_CURRENTHUMIDITY, newMode);
590 newMode = new StringType(attributeValue);
591 updateState(CHANNEL_CURRENTTEMP, newMode);
593 case "SetTemperature":
594 newMode = new StringType(attributeValue);
595 updateState(CHANNEL_TARGETTEMP, newMode);
598 newMode = new StringType(attributeValue);
599 updateState(CHANNEL_AUTOOFFTIME, newMode);
601 case "TimeRemaining":
602 newMode = new StringType(attributeValue);
603 updateState(CHANNEL_HEATINGREMAINING, newMode);
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());
612 updateStatus(ThingStatus.ONLINE);
615 public String getHost() {
616 String localHost = host;
617 if (!localHost.isEmpty()) {
620 UpnpIOService localService = service;
621 if (localService != null) {
622 URL descriptorURL = localService.getDescriptorURL(this);
623 if (descriptorURL != null) {
624 return descriptorURL.getHost();
631 public void onStatusChanged(boolean status) {