]> git.basschouten.com Git - openhab-addons.git/blob
1f3d01f3b4e3d9adf7cdec7689476555b2c8c2e4
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.wemo.internal.handler;
14
15 import static org.openhab.binding.wemo.internal.WemoBindingConstants.*;
16 import static org.openhab.binding.wemo.internal.WemoUtil.*;
17
18 import java.io.StringReader;
19 import java.time.Instant;
20 import java.time.ZonedDateTime;
21 import java.util.Collections;
22 import java.util.Set;
23 import java.util.TimeZone;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
26
27 import javax.xml.parsers.DocumentBuilder;
28 import javax.xml.parsers.DocumentBuilderFactory;
29
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;
53
54 /**
55  * The {@link WemoCoffeeHandler} is responsible for handling commands, which are
56  * sent to one of the channels and to update their states.
57  *
58  * @author Hans-Jörg Merk - Initial contribution
59  * @author Erdoan Hadzhiyusein - Adapted the class to work with the new DateTimeType
60  */
61 @NonNullByDefault
62 public class WemoCoffeeHandler extends WemoBaseThingHandler {
63
64     private final Logger logger = LoggerFactory.getLogger(WemoCoffeeHandler.class);
65
66     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_COFFEE);
67
68     private final Object jobLock = new Object();
69
70     private @Nullable ScheduledFuture<?> pollingJob;
71
72     public WemoCoffeeHandler(Thing thing, UpnpIOService upnpIOService, WemoHttpCall wemoHttpCaller) {
73         super(thing, upnpIOService, wemoHttpCaller);
74
75         logger.debug("Creating a WemoCoffeeHandler for thing '{}'", getThing().getUID());
76     }
77
78     @Override
79     public void initialize() {
80         super.initialize();
81         Configuration configuration = getConfig();
82
83         if (configuration.get(UDN) != null) {
84             logger.debug("Initializing WemoCoffeeHandler for UDN '{}'", configuration.get(UDN));
85             addSubscription(DEVICEEVENT);
86             host = getHost();
87             pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, DEFAULT_REFRESH_INTERVAL_SECONDS,
88                     TimeUnit.SECONDS);
89             updateStatus(ThingStatus.ONLINE);
90         } else {
91             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
92                     "@text/config-status.error.missing-udn");
93             logger.debug("Cannot initalize WemoCoffeeHandler. UDN not set.");
94         }
95     }
96
97     @Override
98     public void dispose() {
99         logger.debug("WemoCoffeeHandler disposed.");
100         ScheduledFuture<?> job = this.pollingJob;
101         if (job != null && !job.isCancelled()) {
102             job.cancel(true);
103         }
104         this.pollingJob = null;
105         super.dispose();
106     }
107
108     private void poll() {
109         synchronized (jobLock) {
110             if (pollingJob == null) {
111                 return;
112             }
113             try {
114                 logger.debug("Polling job");
115
116                 host = getHost();
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() + "\"]");
123                     return;
124                 }
125                 updateWemoState();
126             } catch (Exception e) {
127                 logger.debug("Exception during poll: {}", e.getMessage(), e);
128             }
129         }
130     }
131
132     @Override
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");
140             return;
141         }
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");
148             return;
149         }
150         if (command instanceof RefreshType) {
151             try {
152                 updateWemoState();
153             } catch (Exception e) {
154                 logger.debug("Exception during poll", e);
155             }
156         } else if (channelUID.getId().equals(CHANNEL_STATE)) {
157             if (command instanceof OnOffType) {
158                 if (command.equals(OnOffType.ON)) {
159                     try {
160                         String soapHeader = "\"urn:Belkin:service:deviceevent:1#SetAttributes\"";
161
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>&lt;attribute&gt;&lt;name&gt;Brewed&lt;/name&gt;&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;"
166                                 + "&lt;attribute&gt;&lt;name&gt;LastCleaned&lt;/name&gt;&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;&lt;attribute&gt;"
167                                 + "&lt;name&gt;ModeTime&lt;/name&gt;&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;&lt;attribute&gt;&lt;name&gt;Brewing&lt;/name&gt;"
168                                 + "&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;&lt;attribute&gt;&lt;name&gt;TimeRemaining&lt;/name&gt;&lt;value&gt;NULL&lt;/value&gt;"
169                                 + "&lt;/attribute&gt;&lt;attribute&gt;&lt;name&gt;WaterLevelReached&lt;/name&gt;&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;&lt;"
170                                 + "attribute&gt;&lt;name&gt;Mode&lt;/name&gt;&lt;value&gt;4&lt;/value&gt;&lt;/attribute&gt;&lt;attribute&gt;&lt;name&gt;CleanAdvise&lt;/name&gt;"
171                                 + "&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;&lt;attribute&gt;&lt;name&gt;FilterAdvise&lt;/name&gt;&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;"
172                                 + "&lt;attribute&gt;&lt;name&gt;Cleaning&lt;/name&gt;&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;</attributeList>"
173                                 + "</u:SetAttributes>" + "</s:Body>" + "</s:Envelope>";
174
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(),
182                                 e.getMessage());
183                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
184                     }
185                 }
186                 // if command.equals(OnOffType.OFF) we do nothing because WeMo Coffee Maker cannot be switched
187                 // off remotely
188             }
189         }
190     }
191
192     @Override
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.
195     }
196
197     /**
198      * The {@link updateWemoState} polls the actual state of a WeMo CoffeeMaker.
199      */
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");
206             return;
207         }
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");
214             return;
215         }
216         try {
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);
221             try {
222                 String stringParser = substringBetween(wemoCallResponse, "<attributeList>", "</attributeList>");
223
224                 // Due to Belkins bad response formatting, we need to run this twice.
225                 stringParser = unescapeXml(stringParser);
226                 stringParser = unescapeXml(stringParser);
227
228                 logger.trace("CoffeeMaker response '{}' for device '{}' received", stringParser, getThing().getUID());
229
230                 stringParser = "<data>" + stringParser + "</data>";
231
232                 DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
233                 // see
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));
243
244                 Document doc = db.parse(is);
245                 NodeList nodes = doc.getElementsByTagName("attribute");
246
247                 // iterate the attributes
248                 for (int i = 0; i < nodes.getLength(); i++) {
249                     Element element = (Element) nodes.item(i);
250
251                     NodeList deviceIndex = element.getElementsByTagName("name");
252                     Element line = (Element) deviceIndex.item(0);
253                     String attributeName = getCharacterDataFromElement(line);
254                     logger.trace("attributeName: {}", attributeName);
255
256                     NodeList deviceID = element.getElementsByTagName("value");
257                     line = (Element) deviceID.item(0);
258                     String attributeValue = getCharacterDataFromElement(line);
259                     logger.trace("attributeValue: {}", attributeValue);
260
261                     switch (attributeName) {
262                         case "Mode":
263                             State newMode = new StringType("Brewing");
264                             State newAttributeValue;
265
266                             switch (attributeValue) {
267                                 case "0":
268                                     updateState(CHANNEL_STATE, OnOffType.ON);
269                                     newMode = new StringType("Refill");
270                                     updateState(CHANNEL_COFFEEMODE, newMode);
271                                     break;
272                                 case "1":
273                                     updateState(CHANNEL_STATE, OnOffType.OFF);
274                                     newMode = new StringType("PlaceCarafe");
275                                     updateState(CHANNEL_COFFEEMODE, newMode);
276                                     break;
277                                 case "2":
278                                     updateState(CHANNEL_STATE, OnOffType.OFF);
279                                     newMode = new StringType("RefillWater");
280                                     updateState(CHANNEL_COFFEEMODE, newMode);
281                                     break;
282                                 case "3":
283                                     updateState(CHANNEL_STATE, OnOffType.OFF);
284                                     newMode = new StringType("Ready");
285                                     updateState(CHANNEL_COFFEEMODE, newMode);
286                                     break;
287                                 case "4":
288                                     updateState(CHANNEL_STATE, OnOffType.ON);
289                                     newMode = new StringType("Brewing");
290                                     updateState(CHANNEL_COFFEEMODE, newMode);
291                                     break;
292                                 case "5":
293                                     updateState(CHANNEL_STATE, OnOffType.OFF);
294                                     newMode = new StringType("Brewed");
295                                     updateState(CHANNEL_COFFEEMODE, newMode);
296                                     break;
297                                 case "6":
298                                     updateState(CHANNEL_STATE, OnOffType.OFF);
299                                     newMode = new StringType("CleaningBrewing");
300                                     updateState(CHANNEL_COFFEEMODE, newMode);
301                                     break;
302                                 case "7":
303                                     updateState(CHANNEL_STATE, OnOffType.OFF);
304                                     newMode = new StringType("CleaningSoaking");
305                                     updateState(CHANNEL_COFFEEMODE, newMode);
306                                     break;
307                                 case "8":
308                                     updateState(CHANNEL_STATE, OnOffType.OFF);
309                                     newMode = new StringType("BrewFailCarafeRemoved");
310                                     updateState(CHANNEL_COFFEEMODE, newMode);
311                                     break;
312                             }
313                             break;
314                         case "ModeTime":
315                             newAttributeValue = new DecimalType(attributeValue);
316                             updateState(CHANNEL_MODETIME, newAttributeValue);
317                             break;
318                         case "TimeRemaining":
319                             newAttributeValue = new DecimalType(attributeValue);
320                             updateState(CHANNEL_TIMEREMAINING, newAttributeValue);
321                             break;
322                         case "WaterLevelReached":
323                             newAttributeValue = new DecimalType(attributeValue);
324                             updateState(CHANNEL_WATERLEVELREACHED, newAttributeValue);
325                             break;
326                         case "CleanAdvise":
327                             newAttributeValue = "0".equals(attributeValue) ? OnOffType.OFF : OnOffType.ON;
328                             updateState(CHANNEL_CLEANADVISE, newAttributeValue);
329                             break;
330                         case "FilterAdvise":
331                             newAttributeValue = "0".equals(attributeValue) ? OnOffType.OFF : OnOffType.ON;
332                             updateState(CHANNEL_FILTERADVISE, newAttributeValue);
333                             break;
334                         case "Brewed":
335                             newAttributeValue = getDateTimeState(attributeValue);
336                             if (newAttributeValue != null) {
337                                 updateState(CHANNEL_BREWED, newAttributeValue);
338                             }
339                             break;
340                         case "LastCleaned":
341                             newAttributeValue = getDateTimeState(attributeValue);
342                             if (newAttributeValue != null) {
343                                 updateState(CHANNEL_LASTCLEANED, newAttributeValue);
344                             }
345                             break;
346                     }
347                 }
348                 updateStatus(ThingStatus.ONLINE);
349             } catch (Exception e) {
350                 logger.warn("Failed to parse attributeList for WeMo CoffeMaker '{}'", this.getThing().getUID(), e);
351             }
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());
355         }
356     }
357
358     public @Nullable State getDateTimeState(String attributeValue) {
359         long value = 0;
360         try {
361             value = Long.parseLong(attributeValue);
362         } catch (NumberFormatException e) {
363             logger.warn("Unable to parse attributeValue '{}' for device '{}'; expected long", attributeValue,
364                     getThing().getUID());
365             return null;
366         }
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;
371     }
372 }