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.StringReader;
19 import java.time.Instant;
20 import java.time.ZonedDateTime;
21 import java.util.Collections;
23 import java.util.TimeZone;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
27 import javax.xml.parsers.DocumentBuilder;
28 import javax.xml.parsers.DocumentBuilderFactory;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.binding.wemo.internal.http.WemoHttpCall;
33 import org.openhab.core.config.core.Configuration;
34 import org.openhab.core.io.transport.upnp.UpnpIOService;
35 import org.openhab.core.library.types.DateTimeType;
36 import org.openhab.core.library.types.DecimalType;
37 import org.openhab.core.library.types.OnOffType;
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;
55 * The {@link WemoCoffeeHandler} is responsible for handling commands, which are
56 * sent to one of the channels and to update their states.
58 * @author Hans-Jörg Merk - Initial contribution
59 * @author Erdoan Hadzhiyusein - Adapted the class to work with the new DateTimeType
62 public class WemoCoffeeHandler extends WemoBaseThingHandler {
64 private final Logger logger = LoggerFactory.getLogger(WemoCoffeeHandler.class);
66 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_COFFEE);
68 private final Object jobLock = new Object();
70 private @Nullable ScheduledFuture<?> pollingJob;
72 public WemoCoffeeHandler(Thing thing, UpnpIOService upnpIOService, WemoHttpCall wemoHttpCaller) {
73 super(thing, upnpIOService, wemoHttpCaller);
75 logger.debug("Creating a WemoCoffeeHandler for thing '{}'", getThing().getUID());
79 public void initialize() {
81 Configuration configuration = getConfig();
83 if (configuration.get(UDN) != null) {
84 logger.debug("Initializing WemoCoffeeHandler for UDN '{}'", configuration.get(UDN));
85 addSubscription(DEVICEEVENT);
87 pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, DEFAULT_REFRESH_INTERVAL_SECONDS,
89 updateStatus(ThingStatus.ONLINE);
91 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
92 "@text/config-status.error.missing-udn");
93 logger.debug("Cannot initalize WemoCoffeeHandler. UDN not set.");
98 public void dispose() {
99 logger.debug("WemoCoffeeHandler disposed.");
100 ScheduledFuture<?> job = this.pollingJob;
101 if (job != null && !job.isCancelled()) {
104 this.pollingJob = null;
108 private void poll() {
109 synchronized (jobLock) {
110 if (pollingJob == null) {
114 logger.debug("Polling job");
117 // Check if the Wemo device is set in the UPnP service registry
118 // If not, set the thing state to ONLINE/CONFIG-PENDING and wait for the next poll
119 if (!isUpnpDeviceRegistered()) {
120 logger.debug("UPnP device {} not yet registered", getUDN());
121 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING,
122 "@text/config-status.pending.device-not-registered [\"" + getUDN() + "\"]");
126 } catch (Exception e) {
127 logger.debug("Exception during poll: {}", e.getMessage(), e);
133 public void handleCommand(ChannelUID channelUID, Command command) {
134 String localHost = getHost();
135 if (localHost.isEmpty()) {
136 logger.warn("Failed to send command '{}' for device '{}': IP address missing", command,
137 getThing().getUID());
138 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
139 "@text/config-status.error.missing-ip");
142 String wemoURL = getWemoURL(localHost, BASICACTION);
143 if (wemoURL == null) {
144 logger.debug("Failed to send command '{}' for device '{}': URL cannot be created", command,
145 getThing().getUID());
146 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
147 "@text/config-status.error.missing-url");
150 if (command instanceof RefreshType) {
153 } catch (Exception e) {
154 logger.debug("Exception during poll", e);
156 } else if (channelUID.getId().equals(CHANNEL_STATE)) {
157 if (command instanceof OnOffType) {
158 if (command.equals(OnOffType.ON)) {
160 String soapHeader = "\"urn:Belkin:service:deviceevent:1#SetAttributes\"";
162 String content = "<?xml version=\"1.0\"?>"
163 + "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">"
164 + "<s:Body>" + "<u:SetAttributes xmlns:u=\"urn:Belkin:service:deviceevent:1\">"
165 + "<attributeList><attribute><name>Brewed</name><value>NULL</value></attribute>"
166 + "<attribute><name>LastCleaned</name><value>NULL</value></attribute><attribute>"
167 + "<name>ModeTime</name><value>NULL</value></attribute><attribute><name>Brewing</name>"
168 + "<value>NULL</value></attribute><attribute><name>TimeRemaining</name><value>NULL</value>"
169 + "</attribute><attribute><name>WaterLevelReached</name><value>NULL</value></attribute><"
170 + "attribute><name>Mode</name><value>4</value></attribute><attribute><name>CleanAdvise</name>"
171 + "<value>NULL</value></attribute><attribute><name>FilterAdvise</name><value>NULL</value></attribute>"
172 + "<attribute><name>Cleaning</name><value>NULL</value></attribute></attributeList>"
173 + "</u:SetAttributes>" + "</s:Body>" + "</s:Envelope>";
175 wemoHttpCaller.executeCall(wemoURL, soapHeader, content);
176 updateState(CHANNEL_STATE, OnOffType.ON);
177 State newMode = new StringType("Brewing");
178 updateState(CHANNEL_COFFEEMODE, newMode);
179 updateStatus(ThingStatus.ONLINE);
180 } catch (Exception e) {
181 logger.warn("Failed to send command '{}' for device '{}': {}", command, getThing().getUID(),
183 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
186 // if command.equals(OnOffType.OFF) we do nothing because WeMo Coffee Maker cannot be switched
193 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
194 // We can subscribe to GENA events, but there is no usefull response right now.
198 * The {@link updateWemoState} polls the actual state of a WeMo CoffeeMaker.
200 protected void updateWemoState() {
201 String localHost = getHost();
202 if (localHost.isEmpty()) {
203 logger.warn("Failed to get actual state for device '{}': IP address missing", getThing().getUID());
204 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
205 "@text/config-status.error.missing-ip");
208 String actionService = DEVICEACTION;
209 String wemoURL = getWemoURL(host, actionService);
210 if (wemoURL == null) {
211 logger.debug("Failed to get actual state for device '{}': URL cannot be created", getThing().getUID());
212 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
213 "@text/config-status.error.missing-url");
217 String action = "GetAttributes";
218 String soapHeader = "\"urn:Belkin:service:" + actionService + ":1#" + action + "\"";
219 String content = createStateRequestContent(action, actionService);
220 String wemoCallResponse = wemoHttpCaller.executeCall(wemoURL, soapHeader, content);
222 String stringParser = substringBetween(wemoCallResponse, "<attributeList>", "</attributeList>");
224 // Due to Belkins bad response formatting, we need to run this twice.
225 stringParser = unescapeXml(stringParser);
226 stringParser = unescapeXml(stringParser);
228 logger.trace("CoffeeMaker response '{}' for device '{}' received", stringParser, getThing().getUID());
230 stringParser = "<data>" + stringParser + "</data>";
232 DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
234 // https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
235 dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
236 dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
237 dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
238 dbf.setXIncludeAware(false);
239 dbf.setExpandEntityReferences(false);
240 DocumentBuilder db = dbf.newDocumentBuilder();
241 InputSource is = new InputSource();
242 is.setCharacterStream(new StringReader(stringParser));
244 Document doc = db.parse(is);
245 NodeList nodes = doc.getElementsByTagName("attribute");
247 // iterate the attributes
248 for (int i = 0; i < nodes.getLength(); i++) {
249 Element element = (Element) nodes.item(i);
251 NodeList deviceIndex = element.getElementsByTagName("name");
252 Element line = (Element) deviceIndex.item(0);
253 String attributeName = getCharacterDataFromElement(line);
254 logger.trace("attributeName: {}", attributeName);
256 NodeList deviceID = element.getElementsByTagName("value");
257 line = (Element) deviceID.item(0);
258 String attributeValue = getCharacterDataFromElement(line);
259 logger.trace("attributeValue: {}", attributeValue);
261 switch (attributeName) {
263 State newMode = new StringType("Brewing");
264 State newAttributeValue;
266 switch (attributeValue) {
268 updateState(CHANNEL_STATE, OnOffType.ON);
269 newMode = new StringType("Refill");
270 updateState(CHANNEL_COFFEEMODE, newMode);
273 updateState(CHANNEL_STATE, OnOffType.OFF);
274 newMode = new StringType("PlaceCarafe");
275 updateState(CHANNEL_COFFEEMODE, newMode);
278 updateState(CHANNEL_STATE, OnOffType.OFF);
279 newMode = new StringType("RefillWater");
280 updateState(CHANNEL_COFFEEMODE, newMode);
283 updateState(CHANNEL_STATE, OnOffType.OFF);
284 newMode = new StringType("Ready");
285 updateState(CHANNEL_COFFEEMODE, newMode);
288 updateState(CHANNEL_STATE, OnOffType.ON);
289 newMode = new StringType("Brewing");
290 updateState(CHANNEL_COFFEEMODE, newMode);
293 updateState(CHANNEL_STATE, OnOffType.OFF);
294 newMode = new StringType("Brewed");
295 updateState(CHANNEL_COFFEEMODE, newMode);
298 updateState(CHANNEL_STATE, OnOffType.OFF);
299 newMode = new StringType("CleaningBrewing");
300 updateState(CHANNEL_COFFEEMODE, newMode);
303 updateState(CHANNEL_STATE, OnOffType.OFF);
304 newMode = new StringType("CleaningSoaking");
305 updateState(CHANNEL_COFFEEMODE, newMode);
308 updateState(CHANNEL_STATE, OnOffType.OFF);
309 newMode = new StringType("BrewFailCarafeRemoved");
310 updateState(CHANNEL_COFFEEMODE, newMode);
315 newAttributeValue = new DecimalType(attributeValue);
316 updateState(CHANNEL_MODETIME, newAttributeValue);
318 case "TimeRemaining":
319 newAttributeValue = new DecimalType(attributeValue);
320 updateState(CHANNEL_TIMEREMAINING, newAttributeValue);
322 case "WaterLevelReached":
323 newAttributeValue = new DecimalType(attributeValue);
324 updateState(CHANNEL_WATERLEVELREACHED, newAttributeValue);
327 newAttributeValue = "0".equals(attributeValue) ? OnOffType.OFF : OnOffType.ON;
328 updateState(CHANNEL_CLEANADVISE, newAttributeValue);
331 newAttributeValue = "0".equals(attributeValue) ? OnOffType.OFF : OnOffType.ON;
332 updateState(CHANNEL_FILTERADVISE, newAttributeValue);
335 newAttributeValue = getDateTimeState(attributeValue);
336 if (newAttributeValue != null) {
337 updateState(CHANNEL_BREWED, newAttributeValue);
341 newAttributeValue = getDateTimeState(attributeValue);
342 if (newAttributeValue != null) {
343 updateState(CHANNEL_LASTCLEANED, newAttributeValue);
348 updateStatus(ThingStatus.ONLINE);
349 } catch (Exception e) {
350 logger.warn("Failed to parse attributeList for WeMo CoffeMaker '{}'", this.getThing().getUID(), e);
352 } catch (Exception e) {
353 logger.warn("Failed to get attributes for device '{}'", getThing().getUID(), e);
354 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
358 public @Nullable State getDateTimeState(String attributeValue) {
361 value = Long.parseLong(attributeValue);
362 } catch (NumberFormatException e) {
363 logger.warn("Unable to parse attributeValue '{}' for device '{}'; expected long", attributeValue,
364 getThing().getUID());
367 ZonedDateTime zoned = ZonedDateTime.ofInstant(Instant.ofEpochSecond(value), TimeZone.getDefault().toZoneId());
368 State dateTimeState = new DateTimeType(zoned);
369 logger.trace("New attribute brewed '{}' received", dateTimeState);
370 return dateTimeState;