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;
20 import java.util.Collections;
21 import java.util.HashMap;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
27 import javax.xml.parsers.DocumentBuilder;
28 import javax.xml.parsers.DocumentBuilderFactory;
29 import javax.xml.parsers.ParserConfigurationException;
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;
56 * The {@link WemoHolmesHandler} is responsible for handling commands, which are
57 * sent to one of the channels and to update their states.
59 * @author Hans-Jörg Merk - Initial contribution;
62 public class WemoHolmesHandler extends WemoBaseThingHandler {
64 private final Logger logger = LoggerFactory.getLogger(WemoHolmesHandler.class);
66 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_PURIFIER);
68 private static final int FILTER_LIFE_DAYS = 330;
69 private static final int FILTER_LIFE_MINS = FILTER_LIFE_DAYS * 24 * 60;
71 private final Object upnpLock = new Object();
72 private final Object jobLock = new Object();
74 private final Map<String, String> stateMap = Collections.synchronizedMap(new HashMap<>());
76 private Map<String, Boolean> subscriptionState = new HashMap<>();
78 private @Nullable ScheduledFuture<?> pollingJob;
80 public WemoHolmesHandler(Thing thing, UpnpIOService upnpIOService, WemoHttpCall wemoHttpCaller) {
81 super(thing, upnpIOService, wemoHttpCaller);
83 logger.debug("Creating a WemoHolmesHandler for thing '{}'", getThing().getUID());
87 public void initialize() {
88 Configuration configuration = getConfig();
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);
97 pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, DEFAULT_REFRESH_INTERVAL_SECONDS,
99 updateStatus(ThingStatus.ONLINE);
101 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
102 "@text/config-status.error.missing-udn");
103 logger.debug("Cannot initalize WemoHolmesHandler. UDN not set.");
108 public void dispose() {
109 logger.debug("WemoHolmesHandler disposed.");
111 ScheduledFuture<?> job = this.pollingJob;
112 if (job != null && !job.isCancelled()) {
115 this.pollingJob = null;
116 removeSubscription();
119 private void poll() {
120 synchronized (jobLock) {
121 if (pollingJob == null) {
125 logger.debug("Polling job");
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<>();
138 updateStatus(ThingStatus.ONLINE);
141 } catch (Exception e) {
142 logger.debug("Exception during poll: {}", e.getMessage(), e);
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");
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");
165 String attribute = null;
168 if (command instanceof RefreshType) {
170 } else if (CHANNEL_PURIFIERMODE.equals(channelUID.getId())) {
172 String commandString = command.toString();
173 switch (commandString) {
190 } else if (CHANNEL_IONIZER.equals(channelUID.getId())) {
191 attribute = "Ionizer";
192 if (OnOffType.ON.equals(command)) {
194 } else if (OnOffType.OFF.equals(command)) {
197 } else if (CHANNEL_HUMIDIFIERMODE.equals(channelUID.getId())) {
198 attribute = "FanMode";
199 String commandString = command.toString();
200 switch (commandString) {
220 } else if (CHANNEL_DESIREDHUMIDITY.equals(channelUID.getId())) {
221 attribute = "DesiredHumidity";
222 String commandString = command.toString();
223 switch (commandString) {
240 } else if (CHANNEL_HEATERMODE.equals(channelUID.getId())) {
242 String commandString = command.toString();
243 switch (commandString) {
260 } else if (CHANNEL_TARGETTEMP.equals(channelUID.getId())) {
261 attribute = "SetTemperature";
262 value = command.toString();
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><attribute><name>" + attribute + "</name><value>" + value
270 + "</value></attribute></attributeList>" + "</u:SetAttributes>" + "</s:Body>"
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());
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());
283 updateStatus(ThingStatus.ONLINE);
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);
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());
300 updateStatus(ThingStatus.ONLINE);
301 if (variable != null && value != null) {
302 this.stateMap.put(variable, value);
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());
313 String subscription = BASICEVENT;
315 if (subscriptionState.get(subscription) == null) {
316 logger.debug("Setting up GENA subscription {}: Subscribing to service {}...", getUDN(),
318 localService.addSubscription(this, subscription, SUBSCRIPTION_DURATION_SECONDS);
319 subscriptionState.put(subscription, true);
323 "Setting up WeMo GENA subscription for '{}' FAILED - service.isRegistered(this) is FALSE",
324 getThing().getUID());
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;
338 if (subscriptionState.get(subscription) != null) {
339 logger.debug("WeMo {}: Unsubscribing from service {}...", getUDN(), subscription);
340 localService.removeSubscription(this, subscription);
342 subscriptionState.remove(subscription);
343 localService.unregisterParticipant(this);
350 * The {@link updateWemoState} polls the actual state of a WeMo device and
351 * calls {@link onValueReceived} to update the statemap and channels..
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");
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");
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());
383 String stringParser = substringBetween(wemoCallResponse, "<attributeList>", "</attributeList>");
385 // Due to Belkins bad response formatting, we need to run this twice.
386 stringParser = unescapeXml(stringParser);
387 stringParser = unescapeXml(stringParser);
389 logger.trace("AirPurifier response '{}' for device '{}' received", stringParser, getThing().getUID());
391 stringParser = "<data>" + stringParser + "</data>";
393 DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
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));
405 Document doc = db.parse(is);
406 NodeList nodes = doc.getElementsByTagName("attribute");
408 // iterate the attributes
409 for (int i = 0; i < nodes.getLength(); i++) {
410 Element element = (Element) nodes.item(i);
412 NodeList deviceIndex = element.getElementsByTagName("name");
413 Element line = (Element) deviceIndex.item(0);
414 String attributeName = getCharacterDataFromElement(line);
415 logger.trace("attributeName: {}", attributeName);
417 NodeList deviceID = element.getElementsByTagName("value");
418 line = (Element) deviceID.item(0);
419 String attributeValue = getCharacterDataFromElement(line);
420 logger.trace("attributeValue: {}", attributeValue);
422 State newMode = new StringType();
423 switch (attributeName) {
425 if ("purifier".equals(getThing().getThingTypeUID().getId())) {
426 switch (attributeValue) {
428 newMode = new StringType("OFF");
431 newMode = new StringType("LOW");
434 newMode = new StringType("MED");
437 newMode = new StringType("HIGH");
440 newMode = new StringType("AUTO");
443 updateState(CHANNEL_PURIFIERMODE, newMode);
445 switch (attributeValue) {
447 newMode = new StringType("OFF");
450 newMode = new StringType("FROSTPROTECT");
453 newMode = new StringType("HIGH");
456 newMode = new StringType("LOW");
459 newMode = new StringType("ECO");
462 updateState(CHANNEL_HEATERMODE, newMode);
466 switch (attributeValue) {
468 newMode = OnOffType.OFF;
471 newMode = OnOffType.ON;
474 updateState(CHANNEL_IONIZER, newMode);
477 switch (attributeValue) {
479 newMode = new StringType("POOR");
482 newMode = new StringType("MODERATE");
485 newMode = new StringType("GOOD");
488 updateState(CHANNEL_AIRQUALITY, newMode);
491 int filterLife = Integer.valueOf(attributeValue);
492 if ("purifier".equals(getThing().getThingTypeUID().getId())) {
493 filterLife = Math.round((filterLife / FILTER_LIFE_MINS) * 100);
495 filterLife = Math.round((filterLife / 60480) * 100);
497 updateState(CHANNEL_FILTERLIFE, new PercentType(String.valueOf(filterLife)));
499 case "ExpiredFilterTime":
500 switch (attributeValue) {
502 newMode = OnOffType.OFF;
505 newMode = OnOffType.ON;
508 updateState(CHANNEL_EXPIREDFILTERTIME, newMode);
510 case "FilterPresent":
511 switch (attributeValue) {
513 newMode = OnOffType.OFF;
516 newMode = OnOffType.ON;
519 updateState(CHANNEL_FILTERPRESENT, newMode);
522 switch (attributeValue) {
524 newMode = new StringType("OFF");
527 newMode = new StringType("LOW");
530 newMode = new StringType("MED");
533 newMode = new StringType("HIGH");
536 newMode = new StringType("AUTO");
539 updateState(CHANNEL_PURIFIERMODE, newMode);
541 case "DesiredHumidity":
542 switch (attributeValue) {
544 newMode = new PercentType("45");
547 newMode = new PercentType("50");
550 newMode = new PercentType("55");
553 newMode = new PercentType("60");
556 newMode = new PercentType("100");
559 updateState(CHANNEL_DESIREDHUMIDITY, newMode);
561 case "CurrentHumidity":
562 newMode = new StringType(attributeValue);
563 updateState(CHANNEL_CURRENTHUMIDITY, newMode);
566 newMode = new StringType(attributeValue);
567 updateState(CHANNEL_CURRENTTEMP, newMode);
569 case "SetTemperature":
570 newMode = new StringType(attributeValue);
571 updateState(CHANNEL_TARGETTEMP, newMode);
574 newMode = new StringType(attributeValue);
575 updateState(CHANNEL_AUTOOFFTIME, newMode);
577 case "TimeRemaining":
578 newMode = new StringType(attributeValue);
579 updateState(CHANNEL_HEATINGREMAINING, newMode);
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());
588 updateStatus(ThingStatus.ONLINE);