2 * Copyright (c) 2010-2023 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);
91 pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, DEFAULT_REFRESH_INTERVAL_SECONDS,
93 updateStatus(ThingStatus.UNKNOWN);
95 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
96 "@text/config-status.error.missing-udn");
101 public void dispose() {
102 logger.debug("WemoHolmesHandler disposed.");
104 ScheduledFuture<?> job = this.pollingJob;
105 if (job != null && !job.isCancelled()) {
108 this.pollingJob = null;
112 private void poll() {
113 synchronized (jobLock) {
114 if (pollingJob == null) {
118 logger.debug("Polling job");
119 // Check if the Wemo device is set in the UPnP service registry
120 if (!isUpnpDeviceRegistered()) {
121 logger.debug("UPnP device {} not yet registered", getUDN());
122 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
123 "@text/config-status.pending.device-not-registered [\"" + getUDN() + "\"]");
127 } catch (Exception e) {
128 logger.debug("Exception during poll: {}", e.getMessage(), e);
134 public void handleCommand(ChannelUID channelUID, Command command) {
135 String wemoURL = getWemoURL(DEVICEACTION);
136 if (wemoURL == null) {
137 logger.debug("Failed to send command '{}' for device '{}': URL cannot be created", command,
138 getThing().getUID());
141 String attribute = null;
144 if (command instanceof RefreshType) {
146 } else if (CHANNEL_PURIFIER_MODE.equals(channelUID.getId())) {
148 String commandString = command.toString();
149 switch (commandString) {
166 } else if (CHANNEL_IONIZER.equals(channelUID.getId())) {
167 attribute = "Ionizer";
168 if (OnOffType.ON.equals(command)) {
170 } else if (OnOffType.OFF.equals(command)) {
173 } else if (CHANNEL_HUMIDIFIER_MODE.equals(channelUID.getId())) {
174 attribute = "FanMode";
175 String commandString = command.toString();
176 switch (commandString) {
196 } else if (CHANNEL_DESIRED_HUMIDITY.equals(channelUID.getId())) {
197 attribute = "DesiredHumidity";
198 String commandString = command.toString();
199 switch (commandString) {
216 } else if (CHANNEL_HEATER_MODE.equals(channelUID.getId())) {
218 String commandString = command.toString();
219 switch (commandString) {
236 } else if (CHANNEL_TARGET_TEMPERATURE.equals(channelUID.getId())) {
237 attribute = "SetTemperature";
238 value = command.toString();
241 String soapHeader = "\"urn:Belkin:service:deviceevent:1#SetAttributes\"";
242 String content = "<?xml version=\"1.0\"?>"
243 + "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">"
244 + "<s:Body>" + "<u:SetAttributes xmlns:u=\"urn:Belkin:service:deviceevent:1\">"
245 + "<attributeList><attribute><name>" + attribute + "</name><value>" + value
246 + "</value></attribute></attributeList>" + "</u:SetAttributes>" + "</s:Body>"
248 wemoHttpCaller.executeCall(wemoURL, soapHeader, content);
249 updateStatus(ThingStatus.ONLINE);
250 } catch (IOException e) {
251 logger.debug("Failed to send command '{}' for device '{}':", command, getThing().getUID(), e);
252 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
257 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
258 logger.debug("Received pair '{}':'{}' (service '{}') for thing '{}'", variable, value, service,
259 this.getThing().getUID());
261 updateStatus(ThingStatus.ONLINE);
262 if (variable != null && value != null) {
263 this.stateMap.put(variable, value);
268 * The {@link updateWemoState} polls the actual state of a WeMo device and
269 * calls {@link onValueReceived} to update the statemap and channels..
272 protected void updateWemoState() {
273 String actionService = DEVICEACTION;
274 String wemoURL = getWemoURL(actionService);
275 if (wemoURL == null) {
276 logger.debug("Failed to get actual state for device '{}': URL cannot be created", getThing().getUID());
280 String action = "GetAttributes";
281 String soapHeader = "\"urn:Belkin:service:" + actionService + ":1#" + action + "\"";
282 String content = createStateRequestContent(action, actionService);
283 String wemoCallResponse = wemoHttpCaller.executeCall(wemoURL, soapHeader, content);
284 String stringParser = substringBetween(wemoCallResponse, "<attributeList>", "</attributeList>");
286 // Due to Belkins bad response formatting, we need to run this twice.
287 stringParser = unescapeXml(stringParser);
288 stringParser = unescapeXml(stringParser);
290 logger.trace("AirPurifier response '{}' for device '{}' received", stringParser, getThing().getUID());
292 stringParser = "<data>" + stringParser + "</data>";
294 DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
296 // https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
297 dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
298 dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
299 dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
300 dbf.setXIncludeAware(false);
301 dbf.setExpandEntityReferences(false);
302 DocumentBuilder db = dbf.newDocumentBuilder();
303 InputSource is = new InputSource();
304 is.setCharacterStream(new StringReader(stringParser));
306 Document doc = db.parse(is);
307 NodeList nodes = doc.getElementsByTagName("attribute");
309 // iterate the attributes
310 for (int i = 0; i < nodes.getLength(); i++) {
311 Element element = (Element) nodes.item(i);
313 NodeList deviceIndex = element.getElementsByTagName("name");
314 Element line = (Element) deviceIndex.item(0);
315 String attributeName = getCharacterDataFromElement(line);
316 logger.trace("attributeName: {}", attributeName);
318 NodeList deviceID = element.getElementsByTagName("value");
319 line = (Element) deviceID.item(0);
320 String attributeValue = getCharacterDataFromElement(line);
321 logger.trace("attributeValue: {}", attributeValue);
323 State newMode = new StringType();
324 switch (attributeName) {
326 if ("purifier".equals(getThing().getThingTypeUID().getId())) {
327 switch (attributeValue) {
329 newMode = new StringType("OFF");
332 newMode = new StringType("LOW");
335 newMode = new StringType("MED");
338 newMode = new StringType("HIGH");
341 newMode = new StringType("AUTO");
344 updateState(CHANNEL_PURIFIER_MODE, newMode);
346 switch (attributeValue) {
348 newMode = new StringType("OFF");
351 newMode = new StringType("FROSTPROTECT");
354 newMode = new StringType("HIGH");
357 newMode = new StringType("LOW");
360 newMode = new StringType("ECO");
363 updateState(CHANNEL_HEATER_MODE, newMode);
367 switch (attributeValue) {
369 newMode = OnOffType.OFF;
372 newMode = OnOffType.ON;
375 updateState(CHANNEL_IONIZER, newMode);
378 switch (attributeValue) {
380 newMode = new StringType("POOR");
383 newMode = new StringType("MODERATE");
386 newMode = new StringType("GOOD");
389 updateState(CHANNEL_AIR_QUALITY, newMode);
392 int filterLife = Integer.valueOf(attributeValue);
393 if ("purifier".equals(getThing().getThingTypeUID().getId())) {
394 filterLife = Math.round((filterLife / FILTER_LIFE_MINS) * 100);
396 filterLife = Math.round((filterLife / 60480) * 100);
398 updateState(CHANNEL_FILTER_LIFE, new PercentType(String.valueOf(filterLife)));
400 case "ExpiredFilterTime":
401 switch (attributeValue) {
403 newMode = OnOffType.OFF;
406 newMode = OnOffType.ON;
409 updateState(CHANNEL_EXPIRED_FILTER_TIME, newMode);
411 case "FilterPresent":
412 switch (attributeValue) {
414 newMode = OnOffType.OFF;
417 newMode = OnOffType.ON;
420 updateState(CHANNEL_FILTER_PRESENT, newMode);
423 switch (attributeValue) {
425 newMode = new StringType("OFF");
428 newMode = new StringType("LOW");
431 newMode = new StringType("MED");
434 newMode = new StringType("HIGH");
437 newMode = new StringType("AUTO");
440 updateState(CHANNEL_PURIFIER_MODE, newMode);
442 case "DesiredHumidity":
443 switch (attributeValue) {
445 newMode = new PercentType("45");
448 newMode = new PercentType("50");
451 newMode = new PercentType("55");
454 newMode = new PercentType("60");
457 newMode = new PercentType("100");
460 updateState(CHANNEL_DESIRED_HUMIDITY, newMode);
462 case "CurrentHumidity":
463 newMode = new StringType(attributeValue);
464 updateState(CHANNEL_CURRENT_HUMIDITY, newMode);
467 newMode = new StringType(attributeValue);
468 updateState(CHANNEL_CURRENT_TEMPERATURE, newMode);
470 case "SetTemperature":
471 newMode = new StringType(attributeValue);
472 updateState(CHANNEL_TARGET_TEMPERATURE, newMode);
475 newMode = new StringType(attributeValue);
476 updateState(CHANNEL_AUTO_OFF_TIME, newMode);
478 case "TimeRemaining":
479 newMode = new StringType(attributeValue);
480 updateState(CHANNEL_HEATING_REMAINING, newMode);
484 updateStatus(ThingStatus.ONLINE);
485 } catch (RuntimeException | ParserConfigurationException | SAXException | IOException e) {
486 logger.debug("Failed to get actual state for device '{}':", getThing().getUID(), e);
487 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());