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;
22 import java.util.HashMap;
25 import java.util.TimeZone;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
29 import javax.xml.parsers.DocumentBuilder;
30 import javax.xml.parsers.DocumentBuilderFactory;
32 import org.eclipse.jdt.annotation.NonNullByDefault;
33 import org.eclipse.jdt.annotation.Nullable;
34 import org.openhab.binding.wemo.internal.http.WemoHttpCall;
35 import org.openhab.core.config.core.Configuration;
36 import org.openhab.core.io.transport.upnp.UpnpIOService;
37 import org.openhab.core.library.types.DateTimeType;
38 import org.openhab.core.library.types.DecimalType;
39 import org.openhab.core.library.types.OnOffType;
40 import org.openhab.core.library.types.StringType;
41 import org.openhab.core.thing.ChannelUID;
42 import org.openhab.core.thing.Thing;
43 import org.openhab.core.thing.ThingStatus;
44 import org.openhab.core.thing.ThingStatusDetail;
45 import org.openhab.core.thing.ThingTypeUID;
46 import org.openhab.core.types.Command;
47 import org.openhab.core.types.RefreshType;
48 import org.openhab.core.types.State;
49 import org.slf4j.Logger;
50 import org.slf4j.LoggerFactory;
51 import org.w3c.dom.Document;
52 import org.w3c.dom.Element;
53 import org.w3c.dom.NodeList;
54 import org.xml.sax.InputSource;
57 * The {@link WemoCoffeeHandler} is responsible for handling commands, which are
58 * sent to one of the channels and to update their states.
60 * @author Hans-Jörg Merk - Initial contribution
61 * @author Erdoan Hadzhiyusein - Adapted the class to work with the new DateTimeType
64 public class WemoCoffeeHandler extends WemoBaseThingHandler {
66 private final Logger logger = LoggerFactory.getLogger(WemoCoffeeHandler.class);
68 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_COFFEE);
70 private final Object upnpLock = new Object();
71 private final Object jobLock = new Object();
73 private Map<String, Boolean> subscriptionState = new HashMap<>();
75 private @Nullable ScheduledFuture<?> pollingJob;
77 public WemoCoffeeHandler(Thing thing, UpnpIOService upnpIOService, WemoHttpCall wemoHttpCaller) {
78 super(thing, upnpIOService, wemoHttpCaller);
80 logger.debug("Creating a WemoCoffeeHandler for thing '{}'", getThing().getUID());
84 public void initialize() {
85 Configuration configuration = getConfig();
87 if (configuration.get(UDN) != null) {
88 logger.debug("Initializing WemoCoffeeHandler for UDN '{}'", configuration.get(UDN));
89 UpnpIOService localService = service;
90 if (localService != null) {
91 localService.registerParticipant(this);
94 pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, DEFAULT_REFRESH_INTERVAL_SECONDS,
96 updateStatus(ThingStatus.ONLINE);
98 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
99 "@text/config-status.error.missing-udn");
100 logger.debug("Cannot initalize WemoCoffeeHandler. UDN not set.");
105 public void dispose() {
106 logger.debug("WeMoCoffeeHandler disposed.");
107 ScheduledFuture<?> job = this.pollingJob;
108 if (job != null && !job.isCancelled()) {
111 this.pollingJob = null;
112 removeSubscription();
115 private void poll() {
116 synchronized (jobLock) {
117 if (pollingJob == null) {
121 logger.debug("Polling job");
124 // Check if the Wemo device is set in the UPnP service registry
125 // If not, set the thing state to ONLINE/CONFIG-PENDING and wait for the next poll
126 if (!isUpnpDeviceRegistered()) {
127 logger.debug("UPnP device {} not yet registered", getUDN());
128 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING,
129 "@text/config-status.pending.device-not-registered [\"" + getUDN() + "\"]");
130 synchronized (upnpLock) {
131 subscriptionState = new HashMap<>();
135 updateStatus(ThingStatus.ONLINE);
138 } catch (Exception e) {
139 logger.debug("Exception during poll: {}", e.getMessage(), e);
145 public void handleCommand(ChannelUID channelUID, Command command) {
146 String localHost = getHost();
147 if (localHost.isEmpty()) {
148 logger.error("Failed to send command '{}' for device '{}': IP address missing", command,
149 getThing().getUID());
150 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
151 "@text/config-status.error.missing-ip");
154 String wemoURL = getWemoURL(localHost, BASICACTION);
155 if (wemoURL == null) {
156 logger.error("Failed to send command '{}' for device '{}': URL cannot be created", command,
157 getThing().getUID());
158 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
159 "@text/config-status.error.missing-url");
162 if (command instanceof RefreshType) {
165 } catch (Exception e) {
166 logger.debug("Exception during poll", e);
168 } else if (channelUID.getId().equals(CHANNEL_STATE)) {
169 if (command instanceof OnOffType) {
170 if (command.equals(OnOffType.ON)) {
172 String soapHeader = "\"urn:Belkin:service:deviceevent:1#SetAttributes\"";
174 String content = "<?xml version=\"1.0\"?>"
175 + "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">"
176 + "<s:Body>" + "<u:SetAttributes xmlns:u=\"urn:Belkin:service:deviceevent:1\">"
177 + "<attributeList><attribute><name>Brewed</name><value>NULL</value></attribute>"
178 + "<attribute><name>LastCleaned</name><value>NULL</value></attribute><attribute>"
179 + "<name>ModeTime</name><value>NULL</value></attribute><attribute><name>Brewing</name>"
180 + "<value>NULL</value></attribute><attribute><name>TimeRemaining</name><value>NULL</value>"
181 + "</attribute><attribute><name>WaterLevelReached</name><value>NULL</value></attribute><"
182 + "attribute><name>Mode</name><value>4</value></attribute><attribute><name>CleanAdvise</name>"
183 + "<value>NULL</value></attribute><attribute><name>FilterAdvise</name><value>NULL</value></attribute>"
184 + "<attribute><name>Cleaning</name><value>NULL</value></attribute></attributeList>"
185 + "</u:SetAttributes>" + "</s:Body>" + "</s:Envelope>";
187 String wemoCallResponse = wemoHttpCaller.executeCall(wemoURL, soapHeader, content);
188 if (wemoCallResponse != null) {
189 updateState(CHANNEL_STATE, OnOffType.ON);
190 State newMode = new StringType("Brewing");
191 updateState(CHANNEL_COFFEEMODE, newMode);
192 if (logger.isTraceEnabled()) {
193 logger.trace("wemoCall to URL '{}' for device '{}'", wemoURL, getThing().getUID());
194 logger.trace("wemoCall with soapHeader '{}' for device '{}'", soapHeader,
195 getThing().getUID());
196 logger.trace("wemoCall with content '{}' for device '{}'", content,
197 getThing().getUID());
198 logger.trace("wemoCall with response '{}' for device '{}'", wemoCallResponse,
199 getThing().getUID());
202 } catch (Exception e) {
203 logger.error("Failed to send command '{}' for device '{}': {}", command, getThing().getUID(),
205 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
208 // if command.equals(OnOffType.OFF) we do nothing because WeMo Coffee Maker cannot be switched
211 updateStatus(ThingStatus.ONLINE);
217 public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
218 if (service != null) {
219 logger.debug("WeMo {}: Subscription to service {} {}", getUDN(), service,
220 succeeded ? "succeeded" : "failed");
221 subscriptionState.put(service, succeeded);
226 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
227 // We can subscribe to GENA events, but there is no usefull response right now.
230 private synchronized void addSubscription() {
231 synchronized (upnpLock) {
232 UpnpIOService localService = service;
233 if (localService != null) {
234 if (localService.isRegistered(this)) {
235 logger.debug("Checking WeMo GENA subscription for '{}'", getThing().getUID());
237 String subscription = DEVICEEVENT;
238 if (subscriptionState.get(subscription) == null) {
239 logger.debug("Setting up GENA subscription {}: Subscribing to service {}...", getUDN(),
241 localService.addSubscription(this, subscription, SUBSCRIPTION_DURATION_SECONDS);
242 subscriptionState.put(subscription, true);
246 "Setting up WeMo GENA subscription for '{}' FAILED - service.isRegistered(this) is FALSE",
247 getThing().getUID());
253 private synchronized void removeSubscription() {
254 logger.debug("Removing WeMo GENA subscription for '{}'", getThing().getUID());
255 synchronized (upnpLock) {
256 UpnpIOService localService = service;
257 if (localService != null) {
258 if (localService.isRegistered(this)) {
259 String subscription = DEVICEEVENT;
260 if (subscriptionState.get(subscription) != null) {
261 logger.debug("WeMo {}: Unsubscribing from service {}...", getUDN(), subscription);
262 localService.removeSubscription(this, subscription);
264 subscriptionState = new HashMap<>();
265 localService.unregisterParticipant(this);
272 * The {@link updateWemoState} polls the actual state of a WeMo CoffeeMaker.
274 protected void updateWemoState() {
275 String localHost = getHost();
276 if (localHost.isEmpty()) {
277 logger.error("Failed to get actual state for device '{}': IP address missing", getThing().getUID());
278 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
279 "@text/config-status.error.missing-ip");
282 String actionService = DEVICEACTION;
283 String wemoURL = getWemoURL(host, actionService);
284 if (wemoURL == null) {
285 logger.error("Failed to get actual state for device '{}': URL cannot be created", getThing().getUID());
286 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
287 "@text/config-status.error.missing-url");
291 String action = "GetAttributes";
292 String soapHeader = "\"urn:Belkin:service:" + actionService + ":1#" + action + "\"";
293 String content = createStateRequestContent(action, actionService);
294 String wemoCallResponse = wemoHttpCaller.executeCall(wemoURL, soapHeader, content);
295 if (wemoCallResponse != null) {
296 if (logger.isTraceEnabled()) {
297 logger.trace("wemoCall to URL '{}' for device '{}'", wemoURL, getThing().getUID());
298 logger.trace("wemoCall with soapHeader '{}' for device '{}'", soapHeader, getThing().getUID());
299 logger.trace("wemoCall with content '{}' for device '{}'", content, getThing().getUID());
300 logger.trace("wemoCall with response '{}' for device '{}'", wemoCallResponse, getThing().getUID());
303 String stringParser = substringBetween(wemoCallResponse, "<attributeList>", "</attributeList>");
305 // Due to Belkins bad response formatting, we need to run this twice.
306 stringParser = unescapeXml(stringParser);
307 stringParser = unescapeXml(stringParser);
309 logger.trace("CoffeeMaker response '{}' for device '{}' received", stringParser,
310 getThing().getUID());
312 stringParser = "<data>" + stringParser + "</data>";
314 DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
316 // https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
317 dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
318 dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
319 dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
320 dbf.setXIncludeAware(false);
321 dbf.setExpandEntityReferences(false);
322 DocumentBuilder db = dbf.newDocumentBuilder();
323 InputSource is = new InputSource();
324 is.setCharacterStream(new StringReader(stringParser));
326 Document doc = db.parse(is);
327 NodeList nodes = doc.getElementsByTagName("attribute");
329 // iterate the attributes
330 for (int i = 0; i < nodes.getLength(); i++) {
331 Element element = (Element) nodes.item(i);
333 NodeList deviceIndex = element.getElementsByTagName("name");
334 Element line = (Element) deviceIndex.item(0);
335 String attributeName = getCharacterDataFromElement(line);
336 logger.trace("attributeName: {}", attributeName);
338 NodeList deviceID = element.getElementsByTagName("value");
339 line = (Element) deviceID.item(0);
340 String attributeValue = getCharacterDataFromElement(line);
341 logger.trace("attributeValue: {}", attributeValue);
343 switch (attributeName) {
345 State newMode = new StringType("Brewing");
346 State newAttributeValue;
348 switch (attributeValue) {
350 updateState(CHANNEL_STATE, OnOffType.ON);
351 newMode = new StringType("Refill");
352 updateState(CHANNEL_COFFEEMODE, newMode);
355 updateState(CHANNEL_STATE, OnOffType.OFF);
356 newMode = new StringType("PlaceCarafe");
357 updateState(CHANNEL_COFFEEMODE, newMode);
360 updateState(CHANNEL_STATE, OnOffType.OFF);
361 newMode = new StringType("RefillWater");
362 updateState(CHANNEL_COFFEEMODE, newMode);
365 updateState(CHANNEL_STATE, OnOffType.OFF);
366 newMode = new StringType("Ready");
367 updateState(CHANNEL_COFFEEMODE, newMode);
370 updateState(CHANNEL_STATE, OnOffType.ON);
371 newMode = new StringType("Brewing");
372 updateState(CHANNEL_COFFEEMODE, newMode);
375 updateState(CHANNEL_STATE, OnOffType.OFF);
376 newMode = new StringType("Brewed");
377 updateState(CHANNEL_COFFEEMODE, newMode);
380 updateState(CHANNEL_STATE, OnOffType.OFF);
381 newMode = new StringType("CleaningBrewing");
382 updateState(CHANNEL_COFFEEMODE, newMode);
385 updateState(CHANNEL_STATE, OnOffType.OFF);
386 newMode = new StringType("CleaningSoaking");
387 updateState(CHANNEL_COFFEEMODE, newMode);
390 updateState(CHANNEL_STATE, OnOffType.OFF);
391 newMode = new StringType("BrewFailCarafeRemoved");
392 updateState(CHANNEL_COFFEEMODE, newMode);
397 newAttributeValue = new DecimalType(attributeValue);
398 updateState(CHANNEL_MODETIME, newAttributeValue);
400 case "TimeRemaining":
401 newAttributeValue = new DecimalType(attributeValue);
402 updateState(CHANNEL_TIMEREMAINING, newAttributeValue);
404 case "WaterLevelReached":
405 newAttributeValue = new DecimalType(attributeValue);
406 updateState(CHANNEL_WATERLEVELREACHED, newAttributeValue);
409 newAttributeValue = "0".equals(attributeValue) ? OnOffType.OFF : OnOffType.ON;
410 updateState(CHANNEL_CLEANADVISE, newAttributeValue);
413 newAttributeValue = "0".equals(attributeValue) ? OnOffType.OFF : OnOffType.ON;
414 updateState(CHANNEL_FILTERADVISE, newAttributeValue);
417 newAttributeValue = getDateTimeState(attributeValue);
418 if (newAttributeValue != null) {
419 updateState(CHANNEL_BREWED, newAttributeValue);
423 newAttributeValue = getDateTimeState(attributeValue);
424 if (newAttributeValue != null) {
425 updateState(CHANNEL_LASTCLEANED, newAttributeValue);
430 } catch (Exception e) {
431 logger.error("Failed to parse attributeList for WeMo CoffeMaker '{}'", this.getThing().getUID(), e);
434 } catch (Exception e) {
435 logger.error("Failed to get attributes for device '{}'", getThing().getUID(), e);
439 public @Nullable State getDateTimeState(String attributeValue) {
442 value = Long.parseLong(attributeValue);
443 } catch (NumberFormatException e) {
444 logger.error("Unable to parse attributeValue '{}' for device '{}'; expected long", attributeValue,
445 getThing().getUID());
448 ZonedDateTime zoned = ZonedDateTime.ofInstant(Instant.ofEpochSecond(value), TimeZone.getDefault().toZoneId());
449 State dateTimeState = new DateTimeType(zoned);
450 logger.trace("New attribute brewed '{}' received", dateTimeState);
451 return dateTimeState;