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 jobLock = new Object();
73 private final Map<String, String> stateMap = Collections.synchronizedMap(new HashMap<>());
75 private @Nullable ScheduledFuture<?> pollingJob;
77 public WemoHolmesHandler(Thing thing, UpnpIOService upnpIOService, WemoHttpCall wemoHttpCaller) {
78 super(thing, upnpIOService, wemoHttpCaller);
80 logger.debug("Creating a WemoHolmesHandler for thing '{}'", getThing().getUID());
84 public void initialize() {
86 Configuration configuration = getConfig();
88 if (configuration.get(UDN) != null) {
89 logger.debug("Initializing WemoHolmesHandler for UDN '{}'", configuration.get(UDN));
90 addSubscription(BASICEVENT);
92 pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, DEFAULT_REFRESH_INTERVAL_SECONDS,
94 updateStatus(ThingStatus.ONLINE);
96 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
97 "@text/config-status.error.missing-udn");
98 logger.debug("Cannot initalize WemoHolmesHandler. UDN not set.");
103 public void dispose() {
104 logger.debug("WemoHolmesHandler disposed.");
106 ScheduledFuture<?> job = this.pollingJob;
107 if (job != null && !job.isCancelled()) {
110 this.pollingJob = null;
114 private void poll() {
115 synchronized (jobLock) {
116 if (pollingJob == null) {
120 logger.debug("Polling job");
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() + "\"]");
131 } catch (Exception e) {
132 logger.debug("Exception during poll: {}", e.getMessage(), e);
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");
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");
155 String attribute = null;
158 if (command instanceof RefreshType) {
160 } else if (CHANNEL_PURIFIERMODE.equals(channelUID.getId())) {
162 String commandString = command.toString();
163 switch (commandString) {
180 } else if (CHANNEL_IONIZER.equals(channelUID.getId())) {
181 attribute = "Ionizer";
182 if (OnOffType.ON.equals(command)) {
184 } else if (OnOffType.OFF.equals(command)) {
187 } else if (CHANNEL_HUMIDIFIERMODE.equals(channelUID.getId())) {
188 attribute = "FanMode";
189 String commandString = command.toString();
190 switch (commandString) {
210 } else if (CHANNEL_DESIREDHUMIDITY.equals(channelUID.getId())) {
211 attribute = "DesiredHumidity";
212 String commandString = command.toString();
213 switch (commandString) {
230 } else if (CHANNEL_HEATERMODE.equals(channelUID.getId())) {
232 String commandString = command.toString();
233 switch (commandString) {
250 } else if (CHANNEL_TARGETTEMP.equals(channelUID.getId())) {
251 attribute = "SetTemperature";
252 value = command.toString();
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><attribute><name>" + attribute + "</name><value>" + value
260 + "</value></attribute></attributeList>" + "</u:SetAttributes>" + "</s:Body>"
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());
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());
275 updateStatus(ThingStatus.ONLINE);
276 if (variable != null && value != null) {
277 this.stateMap.put(variable, value);
282 * The {@link updateWemoState} polls the actual state of a WeMo device and
283 * calls {@link onValueReceived} to update the statemap and channels..
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");
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");
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>");
309 // Due to Belkins bad response formatting, we need to run this twice.
310 stringParser = unescapeXml(stringParser);
311 stringParser = unescapeXml(stringParser);
313 logger.trace("AirPurifier response '{}' for device '{}' received", stringParser, getThing().getUID());
315 stringParser = "<data>" + stringParser + "</data>";
317 DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
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));
329 Document doc = db.parse(is);
330 NodeList nodes = doc.getElementsByTagName("attribute");
332 // iterate the attributes
333 for (int i = 0; i < nodes.getLength(); i++) {
334 Element element = (Element) nodes.item(i);
336 NodeList deviceIndex = element.getElementsByTagName("name");
337 Element line = (Element) deviceIndex.item(0);
338 String attributeName = getCharacterDataFromElement(line);
339 logger.trace("attributeName: {}", attributeName);
341 NodeList deviceID = element.getElementsByTagName("value");
342 line = (Element) deviceID.item(0);
343 String attributeValue = getCharacterDataFromElement(line);
344 logger.trace("attributeValue: {}", attributeValue);
346 State newMode = new StringType();
347 switch (attributeName) {
349 if ("purifier".equals(getThing().getThingTypeUID().getId())) {
350 switch (attributeValue) {
352 newMode = new StringType("OFF");
355 newMode = new StringType("LOW");
358 newMode = new StringType("MED");
361 newMode = new StringType("HIGH");
364 newMode = new StringType("AUTO");
367 updateState(CHANNEL_PURIFIERMODE, newMode);
369 switch (attributeValue) {
371 newMode = new StringType("OFF");
374 newMode = new StringType("FROSTPROTECT");
377 newMode = new StringType("HIGH");
380 newMode = new StringType("LOW");
383 newMode = new StringType("ECO");
386 updateState(CHANNEL_HEATERMODE, newMode);
390 switch (attributeValue) {
392 newMode = OnOffType.OFF;
395 newMode = OnOffType.ON;
398 updateState(CHANNEL_IONIZER, newMode);
401 switch (attributeValue) {
403 newMode = new StringType("POOR");
406 newMode = new StringType("MODERATE");
409 newMode = new StringType("GOOD");
412 updateState(CHANNEL_AIRQUALITY, newMode);
415 int filterLife = Integer.valueOf(attributeValue);
416 if ("purifier".equals(getThing().getThingTypeUID().getId())) {
417 filterLife = Math.round((filterLife / FILTER_LIFE_MINS) * 100);
419 filterLife = Math.round((filterLife / 60480) * 100);
421 updateState(CHANNEL_FILTERLIFE, new PercentType(String.valueOf(filterLife)));
423 case "ExpiredFilterTime":
424 switch (attributeValue) {
426 newMode = OnOffType.OFF;
429 newMode = OnOffType.ON;
432 updateState(CHANNEL_EXPIREDFILTERTIME, newMode);
434 case "FilterPresent":
435 switch (attributeValue) {
437 newMode = OnOffType.OFF;
440 newMode = OnOffType.ON;
443 updateState(CHANNEL_FILTERPRESENT, newMode);
446 switch (attributeValue) {
448 newMode = new StringType("OFF");
451 newMode = new StringType("LOW");
454 newMode = new StringType("MED");
457 newMode = new StringType("HIGH");
460 newMode = new StringType("AUTO");
463 updateState(CHANNEL_PURIFIERMODE, newMode);
465 case "DesiredHumidity":
466 switch (attributeValue) {
468 newMode = new PercentType("45");
471 newMode = new PercentType("50");
474 newMode = new PercentType("55");
477 newMode = new PercentType("60");
480 newMode = new PercentType("100");
483 updateState(CHANNEL_DESIREDHUMIDITY, newMode);
485 case "CurrentHumidity":
486 newMode = new StringType(attributeValue);
487 updateState(CHANNEL_CURRENTHUMIDITY, newMode);
490 newMode = new StringType(attributeValue);
491 updateState(CHANNEL_CURRENTTEMP, newMode);
493 case "SetTemperature":
494 newMode = new StringType(attributeValue);
495 updateState(CHANNEL_TARGETTEMP, newMode);
498 newMode = new StringType(attributeValue);
499 updateState(CHANNEL_AUTOOFFTIME, newMode);
501 case "TimeRemaining":
502 newMode = new StringType(attributeValue);
503 updateState(CHANNEL_HEATINGREMAINING, newMode);
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());