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 = Set.of(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\"";
243 <?xml version="1.0"?>\
244 <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">\
246 <u:SetAttributes xmlns:u="urn:Belkin:service:deviceevent:1">\
247 <attributeList><attribute><name>\
249 + attribute + "</name><value>" + value
250 + "</value></attribute></attributeList>" + "</u:SetAttributes>" + "</s:Body>"
252 wemoHttpCaller.executeCall(wemoURL, soapHeader, content);
253 updateStatus(ThingStatus.ONLINE);
254 } catch (IOException e) {
255 logger.debug("Failed to send command '{}' for device '{}':", command, getThing().getUID(), e);
256 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
261 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
262 logger.debug("Received pair '{}':'{}' (service '{}') for thing '{}'", variable, value, service,
263 this.getThing().getUID());
265 updateStatus(ThingStatus.ONLINE);
266 if (variable != null && value != null) {
267 this.stateMap.put(variable, value);
272 * The {@link updateWemoState} polls the actual state of a WeMo device and
273 * calls {@link onValueReceived} to update the statemap and channels..
276 protected void updateWemoState() {
277 String actionService = DEVICEACTION;
278 String wemoURL = getWemoURL(actionService);
279 if (wemoURL == null) {
280 logger.debug("Failed to get actual state for device '{}': URL cannot be created", getThing().getUID());
284 String action = "GetAttributes";
285 String soapHeader = "\"urn:Belkin:service:" + actionService + ":1#" + action + "\"";
286 String content = createStateRequestContent(action, actionService);
287 String wemoCallResponse = wemoHttpCaller.executeCall(wemoURL, soapHeader, content);
288 String stringParser = substringBetween(wemoCallResponse, "<attributeList>", "</attributeList>");
290 // Due to Belkins bad response formatting, we need to run this twice.
291 stringParser = unescapeXml(stringParser);
292 stringParser = unescapeXml(stringParser);
294 logger.trace("AirPurifier response '{}' for device '{}' received", stringParser, getThing().getUID());
296 stringParser = "<data>" + stringParser + "</data>";
298 DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
300 // https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
301 dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
302 dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
303 dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
304 dbf.setXIncludeAware(false);
305 dbf.setExpandEntityReferences(false);
306 DocumentBuilder db = dbf.newDocumentBuilder();
307 InputSource is = new InputSource();
308 is.setCharacterStream(new StringReader(stringParser));
310 Document doc = db.parse(is);
311 NodeList nodes = doc.getElementsByTagName("attribute");
313 // iterate the attributes
314 for (int i = 0; i < nodes.getLength(); i++) {
315 Element element = (Element) nodes.item(i);
317 NodeList deviceIndex = element.getElementsByTagName("name");
318 Element line = (Element) deviceIndex.item(0);
319 String attributeName = getCharacterDataFromElement(line);
320 logger.trace("attributeName: {}", attributeName);
322 NodeList deviceID = element.getElementsByTagName("value");
323 line = (Element) deviceID.item(0);
324 String attributeValue = getCharacterDataFromElement(line);
325 logger.trace("attributeValue: {}", attributeValue);
327 State newMode = new StringType();
328 switch (attributeName) {
330 if ("purifier".equals(getThing().getThingTypeUID().getId())) {
331 switch (attributeValue) {
333 newMode = new StringType("OFF");
336 newMode = new StringType("LOW");
339 newMode = new StringType("MED");
342 newMode = new StringType("HIGH");
345 newMode = new StringType("AUTO");
348 updateState(CHANNEL_PURIFIER_MODE, newMode);
350 switch (attributeValue) {
352 newMode = new StringType("OFF");
355 newMode = new StringType("FROSTPROTECT");
358 newMode = new StringType("HIGH");
361 newMode = new StringType("LOW");
364 newMode = new StringType("ECO");
367 updateState(CHANNEL_HEATER_MODE, newMode);
371 switch (attributeValue) {
373 newMode = OnOffType.OFF;
376 newMode = OnOffType.ON;
379 updateState(CHANNEL_IONIZER, newMode);
382 switch (attributeValue) {
384 newMode = new StringType("POOR");
387 newMode = new StringType("MODERATE");
390 newMode = new StringType("GOOD");
393 updateState(CHANNEL_AIR_QUALITY, newMode);
396 int filterLife = Integer.valueOf(attributeValue);
397 if ("purifier".equals(getThing().getThingTypeUID().getId())) {
398 filterLife = Math.round((filterLife / FILTER_LIFE_MINS) * 100);
400 filterLife = Math.round((filterLife / 60480) * 100);
402 updateState(CHANNEL_FILTER_LIFE, new PercentType(String.valueOf(filterLife)));
404 case "ExpiredFilterTime":
405 switch (attributeValue) {
407 newMode = OnOffType.OFF;
410 newMode = OnOffType.ON;
413 updateState(CHANNEL_EXPIRED_FILTER_TIME, newMode);
415 case "FilterPresent":
416 switch (attributeValue) {
418 newMode = OnOffType.OFF;
421 newMode = OnOffType.ON;
424 updateState(CHANNEL_FILTER_PRESENT, newMode);
427 switch (attributeValue) {
429 newMode = new StringType("OFF");
432 newMode = new StringType("LOW");
435 newMode = new StringType("MED");
438 newMode = new StringType("HIGH");
441 newMode = new StringType("AUTO");
444 updateState(CHANNEL_PURIFIER_MODE, newMode);
446 case "DesiredHumidity":
447 switch (attributeValue) {
449 newMode = new PercentType("45");
452 newMode = new PercentType("50");
455 newMode = new PercentType("55");
458 newMode = new PercentType("60");
461 newMode = new PercentType("100");
464 updateState(CHANNEL_DESIRED_HUMIDITY, newMode);
466 case "CurrentHumidity":
467 newMode = new StringType(attributeValue);
468 updateState(CHANNEL_CURRENT_HUMIDITY, newMode);
471 newMode = new StringType(attributeValue);
472 updateState(CHANNEL_CURRENT_TEMPERATURE, newMode);
474 case "SetTemperature":
475 newMode = new StringType(attributeValue);
476 updateState(CHANNEL_TARGET_TEMPERATURE, newMode);
479 newMode = new StringType(attributeValue);
480 updateState(CHANNEL_AUTO_OFF_TIME, newMode);
482 case "TimeRemaining":
483 newMode = new StringType(attributeValue);
484 updateState(CHANNEL_HEATING_REMAINING, newMode);
488 updateStatus(ThingStatus.ONLINE);
489 } catch (RuntimeException | ParserConfigurationException | SAXException | IOException e) {
490 logger.debug("Failed to get actual state for device '{}':", getThing().getUID(), e);
491 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());