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.math.BigDecimal;
22 import java.util.Collections;
23 import java.util.HashMap;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
29 import javax.xml.parsers.DocumentBuilder;
30 import javax.xml.parsers.DocumentBuilderFactory;
31 import javax.xml.parsers.ParserConfigurationException;
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.openhab.binding.wemo.internal.http.WemoHttpCall;
36 import org.openhab.core.config.core.Configuration;
37 import org.openhab.core.io.transport.upnp.UpnpIOParticipant;
38 import org.openhab.core.io.transport.upnp.UpnpIOService;
39 import org.openhab.core.library.types.OnOffType;
40 import org.openhab.core.library.types.PercentType;
41 import org.openhab.core.library.types.StringType;
42 import org.openhab.core.thing.ChannelUID;
43 import org.openhab.core.thing.Thing;
44 import org.openhab.core.thing.ThingStatus;
45 import org.openhab.core.thing.ThingStatusDetail;
46 import org.openhab.core.thing.ThingTypeUID;
47 import org.openhab.core.types.Command;
48 import org.openhab.core.types.RefreshType;
49 import org.openhab.core.types.State;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
52 import org.w3c.dom.CharacterData;
53 import org.w3c.dom.Document;
54 import org.w3c.dom.Element;
55 import org.w3c.dom.Node;
56 import org.w3c.dom.NodeList;
57 import org.xml.sax.InputSource;
58 import org.xml.sax.SAXException;
61 * The {@link WemoHolmesHandler} is responsible for handling commands, which are
62 * sent to one of the channels and to update their states.
64 * @author Hans-Jörg Merk - Initial contribution;
67 public class WemoHolmesHandler extends AbstractWemoHandler implements UpnpIOParticipant {
69 private final Logger logger = LoggerFactory.getLogger(WemoHolmesHandler.class);
71 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_PURIFIER);
73 private static final int FILTER_LIFE_DAYS = 330;
74 private static final int FILTER_LIFE_MINS = FILTER_LIFE_DAYS * 24 * 60;
75 private final Map<String, Boolean> subscriptionState = new HashMap<>();
76 private final Map<String, String> stateMap = Collections.synchronizedMap(new HashMap<>());
78 private UpnpIOService service;
79 private WemoHttpCall wemoCall;
81 private @Nullable ScheduledFuture<?> refreshJob;
83 private final Runnable refreshRunnable = () -> {
84 if (!isUpnpDeviceRegistered()) {
85 logger.debug("WeMo UPnP device {} not yet registered", getUDN());
92 public WemoHolmesHandler(Thing thing, UpnpIOService upnpIOService, WemoHttpCall wemoHttpCaller) {
93 super(thing, wemoHttpCaller);
95 this.service = upnpIOService;
96 this.wemoCall = wemoHttpCaller;
98 logger.debug("Creating a WemoHolmesHandler for thing '{}'", getThing().getUID());
102 public void initialize() {
103 Configuration configuration = getConfig();
105 if (configuration.get("udn") != null) {
106 logger.debug("Initializing WemoHolmesHandler for UDN '{}'", configuration.get("udn"));
107 service.registerParticipant(this);
110 updateStatus(ThingStatus.ONLINE);
112 logger.debug("Cannot initalize WemoHolmesHandler. UDN not set.");
117 public void dispose() {
118 logger.debug("WemoHolmesHandler disposed.");
120 ScheduledFuture<?> job = refreshJob;
121 if (job != null && !job.isCancelled()) {
125 removeSubscription();
129 public void handleCommand(ChannelUID channelUID, Command command) {
130 logger.trace("Command '{}' received for channel '{}'", command, channelUID);
132 String attribute = null;
135 if (command instanceof RefreshType) {
137 } else if (CHANNEL_PURIFIERMODE.equals(channelUID.getId())) {
139 String commandString = command.toString();
140 switch (commandString) {
157 } else if (CHANNEL_IONIZER.equals(channelUID.getId())) {
158 attribute = "Ionizer";
159 if (OnOffType.ON.equals(command)) {
161 } else if (OnOffType.OFF.equals(command)) {
164 } else if (CHANNEL_HUMIDIFIERMODE.equals(channelUID.getId())) {
165 attribute = "FanMode";
166 String commandString = command.toString();
167 switch (commandString) {
187 } else if (CHANNEL_DESIREDHUMIDITY.equals(channelUID.getId())) {
188 attribute = "DesiredHumidity";
189 String commandString = command.toString();
190 switch (commandString) {
207 } else if (CHANNEL_HEATERMODE.equals(channelUID.getId())) {
209 String commandString = command.toString();
210 switch (commandString) {
227 } else if (CHANNEL_TARGETTEMP.equals(channelUID.getId())) {
228 attribute = "SetTemperature";
229 value = command.toString();
232 String soapHeader = "\"urn:Belkin:service:deviceevent:1#SetAttributes\"";
233 String content = "<?xml version=\"1.0\"?>"
234 + "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">"
235 + "<s:Body>" + "<u:SetAttributes xmlns:u=\"urn:Belkin:service:deviceevent:1\">"
236 + "<attributeList><attribute><name>" + attribute + "</name><value>" + value
237 + "</value></attribute></attributeList>" + "</u:SetAttributes>" + "</s:Body>"
240 URL descriptorURL = service.getDescriptorURL(this);
241 String wemoURL = getWemoURL(descriptorURL, "deviceevent");
243 if (wemoURL != null) {
244 wemoCall.executeCall(wemoURL, soapHeader, content);
246 } catch (RuntimeException e) {
247 logger.debug("Failed to send command '{}' for device '{}':", command, getThing().getUID(), e);
248 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
250 updateStatus(ThingStatus.ONLINE);
254 public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
255 if (service != null) {
256 logger.debug("WeMo {}: Subscription to service {} {}", getUDN(), service,
257 succeeded ? "succeeded" : "failed");
258 subscriptionState.put(service, succeeded);
263 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
264 logger.debug("Received pair '{}':'{}' (service '{}') for thing '{}'", variable, value, service,
265 this.getThing().getUID());
267 updateStatus(ThingStatus.ONLINE);
268 if (variable != null && value != null) {
269 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) {
280 logger.debug("Setting up GENA subscription {}: Subscribing to service {}...", getUDN(), subscription);
281 service.addSubscription(this, subscription, SUBSCRIPTION_DURATION_SECONDS);
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) {
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 ScheduledFuture<?> job = refreshJob;
309 if (job == null || job.isCancelled()) {
310 Configuration config = getThing().getConfiguration();
311 int refreshInterval = DEFAULT_REFRESH_INTERVALL_SECONDS;
312 Object refreshConfig = config.get("refresh");
313 refreshInterval = refreshConfig == null ? DEFAULT_REFRESH_INTERVALL_SECONDS
314 : ((BigDecimal) refreshConfig).intValue();
315 refreshJob = scheduler.scheduleWithFixedDelay(refreshRunnable, 0, refreshInterval, TimeUnit.SECONDS);
319 private boolean isUpnpDeviceRegistered() {
320 return service.isRegistered(this);
324 public String getUDN() {
325 return (String) this.getThing().getConfiguration().get(UDN);
329 * The {@link updateWemoState} polls the actual state of a WeMo device and
330 * calls {@link onValueReceived} to update the statemap and channels..
333 protected void updateWemoState() {
334 String action = "GetAttributes";
335 String actionService = "deviceevent";
337 String soapHeader = "\"urn:Belkin:service:" + actionService + ":1#" + action + "\"";
338 String content = "<?xml version=\"1.0\"?>"
339 + "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">"
340 + "<s:Body>" + "<u:" + action + " xmlns:u=\"urn:Belkin:service:" + actionService + ":1\">" + "</u:"
341 + action + ">" + "</s:Body>" + "</s:Envelope>";
344 URL descriptorURL = service.getDescriptorURL(this);
345 String wemoURL = getWemoURL(descriptorURL, actionService);
347 if (wemoURL != null) {
348 String wemoCallResponse = wemoCall.executeCall(wemoURL, soapHeader, content);
349 if (wemoCallResponse != null) {
350 logger.trace("State response '{}' for device '{}' received", wemoCallResponse, getThing().getUID());
352 String stringParser = substringBetween(wemoCallResponse, "<attributeList>", "</attributeList>");
354 // Due to Belkins bad response formatting, we need to run this twice.
355 stringParser = unescapeXml(stringParser);
356 stringParser = unescapeXml(stringParser);
358 logger.trace("AirPurifier response '{}' for device '{}' received", stringParser,
359 getThing().getUID());
361 stringParser = "<data>" + stringParser + "</data>";
363 DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
365 // https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
366 dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
367 dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
368 dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
369 dbf.setXIncludeAware(false);
370 dbf.setExpandEntityReferences(false);
371 DocumentBuilder db = dbf.newDocumentBuilder();
372 InputSource is = new InputSource();
373 is.setCharacterStream(new StringReader(stringParser));
375 Document doc = db.parse(is);
376 NodeList nodes = doc.getElementsByTagName("attribute");
378 // iterate the attributes
379 for (int i = 0; i < nodes.getLength(); i++) {
380 Element element = (Element) nodes.item(i);
382 NodeList deviceIndex = element.getElementsByTagName("name");
383 Element line = (Element) deviceIndex.item(0);
384 String attributeName = getCharacterDataFromElement(line);
385 logger.trace("attributeName: {}", attributeName);
387 NodeList deviceID = element.getElementsByTagName("value");
388 line = (Element) deviceID.item(0);
389 String attributeValue = getCharacterDataFromElement(line);
390 logger.trace("attributeValue: {}", attributeValue);
392 State newMode = new StringType();
393 switch (attributeName) {
395 if ("purifier".equals(getThing().getThingTypeUID().getId())) {
396 switch (attributeValue) {
398 newMode = new StringType("OFF");
401 newMode = new StringType("LOW");
404 newMode = new StringType("MED");
407 newMode = new StringType("HIGH");
410 newMode = new StringType("AUTO");
413 updateState(CHANNEL_PURIFIERMODE, newMode);
415 switch (attributeValue) {
417 newMode = new StringType("OFF");
420 newMode = new StringType("FROSTPROTECT");
423 newMode = new StringType("HIGH");
426 newMode = new StringType("LOW");
429 newMode = new StringType("ECO");
432 updateState(CHANNEL_HEATERMODE, newMode);
436 switch (attributeValue) {
438 newMode = OnOffType.OFF;
441 newMode = OnOffType.ON;
444 updateState(CHANNEL_IONIZER, newMode);
447 switch (attributeValue) {
449 newMode = new StringType("POOR");
452 newMode = new StringType("MODERATE");
455 newMode = new StringType("GOOD");
458 updateState(CHANNEL_AIRQUALITY, newMode);
461 int filterLife = Integer.valueOf(attributeValue);
462 if ("purifier".equals(getThing().getThingTypeUID().getId())) {
463 filterLife = Math.round((filterLife / FILTER_LIFE_MINS) * 100);
465 filterLife = Math.round((filterLife / 60480) * 100);
467 updateState(CHANNEL_FILTERLIFE, new PercentType(String.valueOf(filterLife)));
469 case "ExpiredFilterTime":
470 switch (attributeValue) {
472 newMode = OnOffType.OFF;
475 newMode = OnOffType.ON;
478 updateState(CHANNEL_EXPIREDFILTERTIME, newMode);
480 case "FilterPresent":
481 switch (attributeValue) {
483 newMode = OnOffType.OFF;
486 newMode = OnOffType.ON;
489 updateState(CHANNEL_FILTERPRESENT, newMode);
492 switch (attributeValue) {
494 newMode = new StringType("OFF");
497 newMode = new StringType("LOW");
500 newMode = new StringType("MED");
503 newMode = new StringType("HIGH");
506 newMode = new StringType("AUTO");
509 updateState(CHANNEL_PURIFIERMODE, newMode);
511 case "DesiredHumidity":
512 switch (attributeValue) {
514 newMode = new PercentType("45");
517 newMode = new PercentType("50");
520 newMode = new PercentType("55");
523 newMode = new PercentType("60");
526 newMode = new PercentType("100");
529 updateState(CHANNEL_DESIREDHUMIDITY, newMode);
531 case "CurrentHumidity":
532 newMode = new StringType(attributeValue);
533 updateState(CHANNEL_CURRENTHUMIDITY, newMode);
536 newMode = new StringType(attributeValue);
537 updateState(CHANNEL_CURRENTTEMP, newMode);
539 case "SetTemperature":
540 newMode = new StringType(attributeValue);
541 updateState(CHANNEL_TARGETTEMP, newMode);
544 newMode = new StringType(attributeValue);
545 updateState(CHANNEL_AUTOOFFTIME, newMode);
547 case "TimeRemaining":
548 newMode = new StringType(attributeValue);
549 updateState(CHANNEL_HEATINGREMAINING, newMode);
555 } catch (RuntimeException | ParserConfigurationException | SAXException | IOException e) {
556 logger.debug("Failed to get actual state for device '{}':", getThing().getUID(), e);
557 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
559 updateStatus(ThingStatus.ONLINE);
562 public static String getCharacterDataFromElement(Element e) {
563 Node child = e.getFirstChild();
564 if (child instanceof CharacterData) {
565 CharacterData cd = (CharacterData) child;
572 public void onStatusChanged(boolean status) {