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;
20 import java.time.Instant;
21 import java.time.ZonedDateTime;
22 import java.util.Collections;
23 import java.util.HashMap;
26 import java.util.TimeZone;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
30 import javax.xml.parsers.DocumentBuilder;
31 import javax.xml.parsers.DocumentBuilderFactory;
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.openhab.binding.wemo.internal.http.WemoHttpCall;
36 import org.openhab.core.config.core.Configuration;
37 import org.openhab.core.io.transport.upnp.UpnpIOParticipant;
38 import org.openhab.core.io.transport.upnp.UpnpIOService;
39 import org.openhab.core.library.types.DateTimeType;
40 import org.openhab.core.library.types.DecimalType;
41 import org.openhab.core.library.types.OnOffType;
42 import org.openhab.core.library.types.StringType;
43 import org.openhab.core.thing.ChannelUID;
44 import org.openhab.core.thing.Thing;
45 import org.openhab.core.thing.ThingStatus;
46 import org.openhab.core.thing.ThingStatusDetail;
47 import org.openhab.core.thing.ThingTypeUID;
48 import org.openhab.core.types.Command;
49 import org.openhab.core.types.RefreshType;
50 import org.openhab.core.types.State;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
53 import org.w3c.dom.Document;
54 import org.w3c.dom.Element;
55 import org.w3c.dom.NodeList;
56 import org.xml.sax.InputSource;
59 * The {@link WemoCoffeeHandler} is responsible for handling commands, which are
60 * sent to one of the channels and to update their states.
62 * @author Hans-Jörg Merk - Initial contribution
63 * @author Erdoan Hadzhiyusein - Adapted the class to work with the new DateTimeType
66 public class WemoCoffeeHandler extends AbstractWemoHandler implements UpnpIOParticipant {
68 private final Logger logger = LoggerFactory.getLogger(WemoCoffeeHandler.class);
70 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_COFFEE);
72 private final Object upnpLock = new Object();
73 private final Object jobLock = new Object();
75 private @Nullable UpnpIOService service;
77 private WemoHttpCall wemoCall;
79 private String host = "";
81 private Map<String, Boolean> subscriptionState = new HashMap<>();
83 private @Nullable ScheduledFuture<?> pollingJob;
85 public WemoCoffeeHandler(Thing thing, UpnpIOService upnpIOService, WemoHttpCall wemoHttpCaller) {
86 super(thing, wemoHttpCaller);
88 this.wemoCall = wemoHttpCaller;
89 this.service = upnpIOService;
91 logger.debug("Creating a WemoCoffeeHandler for thing '{}'", getThing().getUID());
95 public void initialize() {
96 Configuration configuration = getConfig();
98 if (configuration.get(UDN) != null) {
99 logger.debug("Initializing WemoCoffeeHandler for UDN '{}'", configuration.get(UDN));
100 UpnpIOService localService = service;
101 if (localService != null) {
102 localService.registerParticipant(this);
105 pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, DEFAULT_REFRESH_INTERVALL_SECONDS,
107 updateStatus(ThingStatus.ONLINE);
109 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
110 "@text/config-status.error.missing-udn");
111 logger.debug("Cannot initalize WemoCoffeeHandler. UDN not set.");
116 public void dispose() {
117 logger.debug("WeMoCoffeeHandler disposed.");
118 ScheduledFuture<?> job = this.pollingJob;
119 if (job != null && !job.isCancelled()) {
122 this.pollingJob = null;
123 removeSubscription();
126 private void poll() {
127 synchronized (jobLock) {
128 if (pollingJob == null) {
132 logger.debug("Polling job");
135 // Check if the Wemo device is set in the UPnP service registry
136 // If not, set the thing state to ONLINE/CONFIG-PENDING and wait for the next poll
137 if (!isUpnpDeviceRegistered()) {
138 logger.debug("UPnP device {} not yet registered", getUDN());
139 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING,
140 "@text/config-status.pending.device-not-registered [\"" + getUDN() + "\"]");
141 synchronized (upnpLock) {
142 subscriptionState = new HashMap<>();
146 updateStatus(ThingStatus.ONLINE);
149 } catch (Exception e) {
150 logger.debug("Exception during poll: {}", e.getMessage(), e);
156 public void handleCommand(ChannelUID channelUID, Command command) {
157 String localHost = getHost();
158 if (localHost.isEmpty()) {
159 logger.error("Failed to send command '{}' for device '{}': IP address missing", command,
160 getThing().getUID());
161 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
162 "@text/config-status.error.missing-ip");
165 String wemoURL = getWemoURL(localHost, BASICACTION);
166 if (wemoURL == null) {
167 logger.error("Failed to send command '{}' for device '{}': URL cannot be created", command,
168 getThing().getUID());
169 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
170 "@text/config-status.error.missing-url");
173 if (command instanceof RefreshType) {
176 } catch (Exception e) {
177 logger.debug("Exception during poll", e);
179 } else if (channelUID.getId().equals(CHANNEL_STATE)) {
180 if (command instanceof OnOffType) {
181 if (command.equals(OnOffType.ON)) {
183 String soapHeader = "\"urn:Belkin:service:deviceevent:1#SetAttributes\"";
185 String content = "<?xml version=\"1.0\"?>"
186 + "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">"
187 + "<s:Body>" + "<u:SetAttributes xmlns:u=\"urn:Belkin:service:deviceevent:1\">"
188 + "<attributeList><attribute><name>Brewed</name><value>NULL</value></attribute>"
189 + "<attribute><name>LastCleaned</name><value>NULL</value></attribute><attribute>"
190 + "<name>ModeTime</name><value>NULL</value></attribute><attribute><name>Brewing</name>"
191 + "<value>NULL</value></attribute><attribute><name>TimeRemaining</name><value>NULL</value>"
192 + "</attribute><attribute><name>WaterLevelReached</name><value>NULL</value></attribute><"
193 + "attribute><name>Mode</name><value>4</value></attribute><attribute><name>CleanAdvise</name>"
194 + "<value>NULL</value></attribute><attribute><name>FilterAdvise</name><value>NULL</value></attribute>"
195 + "<attribute><name>Cleaning</name><value>NULL</value></attribute></attributeList>"
196 + "</u:SetAttributes>" + "</s:Body>" + "</s:Envelope>";
198 String wemoCallResponse = wemoCall.executeCall(wemoURL, soapHeader, content);
199 if (wemoCallResponse != null) {
200 updateState(CHANNEL_STATE, OnOffType.ON);
201 State newMode = new StringType("Brewing");
202 updateState(CHANNEL_COFFEEMODE, newMode);
203 if (logger.isTraceEnabled()) {
204 logger.trace("wemoCall to URL '{}' for device '{}'", wemoURL, getThing().getUID());
205 logger.trace("wemoCall with soapHeader '{}' for device '{}'", soapHeader,
206 getThing().getUID());
207 logger.trace("wemoCall with content '{}' for device '{}'", content,
208 getThing().getUID());
209 logger.trace("wemoCall with response '{}' for device '{}'", wemoCallResponse,
210 getThing().getUID());
213 } catch (Exception e) {
214 logger.error("Failed to send command '{}' for device '{}': {}", command, getThing().getUID(),
216 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
219 // if command.equals(OnOffType.OFF) we do nothing because WeMo Coffee Maker cannot be switched
222 updateStatus(ThingStatus.ONLINE);
228 public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
229 if (service != null) {
230 logger.debug("WeMo {}: Subscription to service {} {}", getUDN(), service,
231 succeeded ? "succeeded" : "failed");
232 subscriptionState.put(service, succeeded);
237 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
238 // We can subscribe to GENA events, but there is no usefull response right now.
241 private synchronized void addSubscription() {
242 synchronized (upnpLock) {
243 UpnpIOService localService = service;
244 if (localService != null) {
245 if (localService.isRegistered(this)) {
246 logger.debug("Checking WeMo GENA subscription for '{}'", getThing().getUID());
248 String subscription = DEVICEEVENT;
249 if (subscriptionState.get(subscription) == null) {
250 logger.debug("Setting up GENA subscription {}: Subscribing to service {}...", getUDN(),
252 localService.addSubscription(this, subscription, SUBSCRIPTION_DURATION_SECONDS);
253 subscriptionState.put(subscription, true);
257 "Setting up WeMo GENA subscription for '{}' FAILED - service.isRegistered(this) is FALSE",
258 getThing().getUID());
264 private synchronized void removeSubscription() {
265 logger.debug("Removing WeMo GENA subscription for '{}'", getThing().getUID());
266 synchronized (upnpLock) {
267 UpnpIOService localService = service;
268 if (localService != null) {
269 if (localService.isRegistered(this)) {
270 String subscription = DEVICEEVENT;
271 if (subscriptionState.get(subscription) != null) {
272 logger.debug("WeMo {}: Unsubscribing from service {}...", getUDN(), subscription);
273 localService.removeSubscription(this, subscription);
275 subscriptionState = new HashMap<>();
276 localService.unregisterParticipant(this);
282 private boolean isUpnpDeviceRegistered() {
283 UpnpIOService localService = service;
284 if (localService != null) {
285 return localService.isRegistered(this);
291 public String getUDN() {
292 return (String) this.getThing().getConfiguration().get(UDN);
296 * The {@link updateWemoState} polls the actual state of a WeMo CoffeeMaker.
298 protected void updateWemoState() {
299 String localHost = getHost();
300 if (localHost.isEmpty()) {
301 logger.error("Failed to get actual state for device '{}': IP address missing", getThing().getUID());
302 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
303 "@text/config-status.error.missing-ip");
306 String actionService = DEVICEACTION;
307 String wemoURL = getWemoURL(host, actionService);
308 if (wemoURL == null) {
309 logger.error("Failed to get actual state for device '{}': URL cannot be created", getThing().getUID());
310 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
311 "@text/config-status.error.missing-url");
315 String action = "GetAttributes";
316 String soapHeader = "\"urn:Belkin:service:" + actionService + ":1#" + action + "\"";
317 String content = createStateRequestContent(action, actionService);
318 String wemoCallResponse = wemoCall.executeCall(wemoURL, soapHeader, content);
319 if (wemoCallResponse != null) {
320 if (logger.isTraceEnabled()) {
321 logger.trace("wemoCall to URL '{}' for device '{}'", wemoURL, getThing().getUID());
322 logger.trace("wemoCall with soapHeader '{}' for device '{}'", soapHeader, getThing().getUID());
323 logger.trace("wemoCall with content '{}' for device '{}'", content, getThing().getUID());
324 logger.trace("wemoCall with response '{}' for device '{}'", wemoCallResponse, getThing().getUID());
327 String stringParser = substringBetween(wemoCallResponse, "<attributeList>", "</attributeList>");
329 // Due to Belkins bad response formatting, we need to run this twice.
330 stringParser = unescapeXml(stringParser);
331 stringParser = unescapeXml(stringParser);
333 logger.trace("CoffeeMaker response '{}' for device '{}' received", stringParser,
334 getThing().getUID());
336 stringParser = "<data>" + stringParser + "</data>";
338 DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
340 // https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
341 dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
342 dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
343 dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
344 dbf.setXIncludeAware(false);
345 dbf.setExpandEntityReferences(false);
346 DocumentBuilder db = dbf.newDocumentBuilder();
347 InputSource is = new InputSource();
348 is.setCharacterStream(new StringReader(stringParser));
350 Document doc = db.parse(is);
351 NodeList nodes = doc.getElementsByTagName("attribute");
353 // iterate the attributes
354 for (int i = 0; i < nodes.getLength(); i++) {
355 Element element = (Element) nodes.item(i);
357 NodeList deviceIndex = element.getElementsByTagName("name");
358 Element line = (Element) deviceIndex.item(0);
359 String attributeName = getCharacterDataFromElement(line);
360 logger.trace("attributeName: {}", attributeName);
362 NodeList deviceID = element.getElementsByTagName("value");
363 line = (Element) deviceID.item(0);
364 String attributeValue = getCharacterDataFromElement(line);
365 logger.trace("attributeValue: {}", attributeValue);
367 switch (attributeName) {
369 State newMode = new StringType("Brewing");
370 State newAttributeValue;
372 switch (attributeValue) {
374 updateState(CHANNEL_STATE, OnOffType.ON);
375 newMode = new StringType("Refill");
376 updateState(CHANNEL_COFFEEMODE, newMode);
379 updateState(CHANNEL_STATE, OnOffType.OFF);
380 newMode = new StringType("PlaceCarafe");
381 updateState(CHANNEL_COFFEEMODE, newMode);
384 updateState(CHANNEL_STATE, OnOffType.OFF);
385 newMode = new StringType("RefillWater");
386 updateState(CHANNEL_COFFEEMODE, newMode);
389 updateState(CHANNEL_STATE, OnOffType.OFF);
390 newMode = new StringType("Ready");
391 updateState(CHANNEL_COFFEEMODE, newMode);
394 updateState(CHANNEL_STATE, OnOffType.ON);
395 newMode = new StringType("Brewing");
396 updateState(CHANNEL_COFFEEMODE, newMode);
399 updateState(CHANNEL_STATE, OnOffType.OFF);
400 newMode = new StringType("Brewed");
401 updateState(CHANNEL_COFFEEMODE, newMode);
404 updateState(CHANNEL_STATE, OnOffType.OFF);
405 newMode = new StringType("CleaningBrewing");
406 updateState(CHANNEL_COFFEEMODE, newMode);
409 updateState(CHANNEL_STATE, OnOffType.OFF);
410 newMode = new StringType("CleaningSoaking");
411 updateState(CHANNEL_COFFEEMODE, newMode);
414 updateState(CHANNEL_STATE, OnOffType.OFF);
415 newMode = new StringType("BrewFailCarafeRemoved");
416 updateState(CHANNEL_COFFEEMODE, newMode);
421 newAttributeValue = new DecimalType(attributeValue);
422 updateState(CHANNEL_MODETIME, newAttributeValue);
424 case "TimeRemaining":
425 newAttributeValue = new DecimalType(attributeValue);
426 updateState(CHANNEL_TIMEREMAINING, newAttributeValue);
428 case "WaterLevelReached":
429 newAttributeValue = new DecimalType(attributeValue);
430 updateState(CHANNEL_WATERLEVELREACHED, newAttributeValue);
433 newAttributeValue = "0".equals(attributeValue) ? OnOffType.OFF : OnOffType.ON;
434 updateState(CHANNEL_CLEANADVISE, newAttributeValue);
437 newAttributeValue = "0".equals(attributeValue) ? OnOffType.OFF : OnOffType.ON;
438 updateState(CHANNEL_FILTERADVISE, newAttributeValue);
441 newAttributeValue = getDateTimeState(attributeValue);
442 if (newAttributeValue != null) {
443 updateState(CHANNEL_BREWED, newAttributeValue);
447 newAttributeValue = getDateTimeState(attributeValue);
448 if (newAttributeValue != null) {
449 updateState(CHANNEL_LASTCLEANED, newAttributeValue);
454 } catch (Exception e) {
455 logger.error("Failed to parse attributeList for WeMo CoffeMaker '{}'", this.getThing().getUID(), e);
458 } catch (Exception e) {
459 logger.error("Failed to get attributes for device '{}'", getThing().getUID(), e);
463 public @Nullable State getDateTimeState(String attributeValue) {
466 value = Long.parseLong(attributeValue);
467 } catch (NumberFormatException e) {
468 logger.error("Unable to parse attributeValue '{}' for device '{}'; expected long", attributeValue,
469 getThing().getUID());
472 ZonedDateTime zoned = ZonedDateTime.ofInstant(Instant.ofEpochSecond(value), TimeZone.getDefault().toZoneId());
473 State dateTimeState = new DateTimeType(zoned);
474 logger.trace("New attribute brewed '{}' received", dateTimeState);
475 return dateTimeState;
478 public String getHost() {
479 String localHost = host;
480 if (!localHost.isEmpty()) {
483 UpnpIOService localService = service;
484 if (localService != null) {
485 URL descriptorURL = localService.getDescriptorURL(this);
486 if (descriptorURL != null) {
487 return descriptorURL.getHost();
494 public void onStatusChanged(boolean status) {