]> git.basschouten.com Git - openhab-addons.git/blob
0ccbbcffef564a30eb59a24be66eb16a451e9eb2
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.Set;
22 import java.util.TimeZone;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
25
26 import javax.xml.parsers.DocumentBuilder;
27 import javax.xml.parsers.DocumentBuilderFactory;
28
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.openhab.binding.wemo.internal.http.WemoHttpCall;
32 import org.openhab.core.config.core.Configuration;
33 import org.openhab.core.io.transport.upnp.UpnpIOService;
34 import org.openhab.core.library.types.DateTimeType;
35 import org.openhab.core.library.types.DecimalType;
36 import org.openhab.core.library.types.OnOffType;
37 import org.openhab.core.library.types.StringType;
38 import org.openhab.core.thing.ChannelUID;
39 import org.openhab.core.thing.Thing;
40 import org.openhab.core.thing.ThingStatus;
41 import org.openhab.core.thing.ThingStatusDetail;
42 import org.openhab.core.thing.ThingTypeUID;
43 import org.openhab.core.types.Command;
44 import org.openhab.core.types.RefreshType;
45 import org.openhab.core.types.State;
46 import org.slf4j.Logger;
47 import org.slf4j.LoggerFactory;
48 import org.w3c.dom.Document;
49 import org.w3c.dom.Element;
50 import org.w3c.dom.NodeList;
51 import org.xml.sax.InputSource;
52
53 /**
54  * The {@link WemoCoffeeHandler} is responsible for handling commands, which are
55  * sent to one of the channels and to update their states.
56  *
57  * @author Hans-Jörg Merk - Initial contribution
58  * @author Erdoan Hadzhiyusein - Adapted the class to work with the new DateTimeType
59  */
60 @NonNullByDefault
61 public class WemoCoffeeHandler extends WemoBaseThingHandler {
62
63     private final Logger logger = LoggerFactory.getLogger(WemoCoffeeHandler.class);
64
65     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_COFFEE);
66
67     private final Object jobLock = new Object();
68
69     private @Nullable ScheduledFuture<?> pollingJob;
70
71     public WemoCoffeeHandler(Thing thing, UpnpIOService upnpIOService, WemoHttpCall wemoHttpCaller) {
72         super(thing, upnpIOService, wemoHttpCaller);
73
74         logger.debug("Creating a WemoCoffeeHandler for thing '{}'", getThing().getUID());
75     }
76
77     @Override
78     public void initialize() {
79         super.initialize();
80         Configuration configuration = getConfig();
81
82         if (configuration.get(UDN) != null) {
83             logger.debug("Initializing WemoCoffeeHandler for UDN '{}'", configuration.get(UDN));
84             addSubscription(DEVICEEVENT);
85             pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, DEFAULT_REFRESH_INTERVAL_SECONDS,
86                     TimeUnit.SECONDS);
87             updateStatus(ThingStatus.UNKNOWN);
88         } else {
89             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
90                     "@text/config-status.error.missing-udn");
91         }
92     }
93
94     @Override
95     public void dispose() {
96         logger.debug("WemoCoffeeHandler disposed.");
97         ScheduledFuture<?> job = this.pollingJob;
98         if (job != null && !job.isCancelled()) {
99             job.cancel(true);
100         }
101         this.pollingJob = null;
102         super.dispose();
103     }
104
105     private void poll() {
106         synchronized (jobLock) {
107             if (pollingJob == null) {
108                 return;
109             }
110             try {
111                 logger.debug("Polling job");
112
113                 // Check if the Wemo device is set in the UPnP service registry
114                 if (!isUpnpDeviceRegistered()) {
115                     logger.debug("UPnP device {} not yet registered", getUDN());
116                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
117                             "@text/config-status.pending.device-not-registered [\"" + getUDN() + "\"]");
118                     return;
119                 }
120                 updateWemoState();
121             } catch (Exception e) {
122                 logger.debug("Exception during poll: {}", e.getMessage(), e);
123             }
124         }
125     }
126
127     @Override
128     public void handleCommand(ChannelUID channelUID, Command command) {
129         String wemoURL = getWemoURL(BASICACTION);
130         if (wemoURL == null) {
131             logger.debug("Failed to send command '{}' for device '{}': URL cannot be created", command,
132                     getThing().getUID());
133             return;
134         }
135         if (command instanceof RefreshType) {
136             try {
137                 updateWemoState();
138             } catch (Exception e) {
139                 logger.debug("Exception during poll", e);
140             }
141         } else if (channelUID.getId().equals(CHANNEL_STATE)) {
142             if (command instanceof OnOffType) {
143                 if (command.equals(OnOffType.ON)) {
144                     try {
145                         String soapHeader = "\"urn:Belkin:service:deviceevent:1#SetAttributes\"";
146
147                         String content = """
148                                 <?xml version="1.0"?>\
149                                 <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">\
150                                 <s:Body>\
151                                 <u:SetAttributes xmlns:u="urn:Belkin:service:deviceevent:1">\
152                                 <attributeList>&lt;attribute&gt;&lt;name&gt;Brewed&lt;/name&gt;&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;\
153                                 &lt;attribute&gt;&lt;name&gt;LastCleaned&lt;/name&gt;&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;&lt;attribute&gt;\
154                                 &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;\
155                                 &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;\
156                                 &lt;/attribute&gt;&lt;attribute&gt;&lt;name&gt;WaterLevelReached&lt;/name&gt;&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;&lt;\
157                                 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;\
158                                 &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;\
159                                 &lt;attribute&gt;&lt;name&gt;Cleaning&lt;/name&gt;&lt;value&gt;NULL&lt;/value&gt;&lt;/attribute&gt;</attributeList>\
160                                 </u:SetAttributes>\
161                                 </s:Body>\
162                                 </s:Envelope>\
163                                 """;
164
165                         wemoHttpCaller.executeCall(wemoURL, soapHeader, content);
166                         updateState(CHANNEL_STATE, OnOffType.ON);
167                         State newMode = new StringType("Brewing");
168                         updateState(CHANNEL_COFFEE_MODE, newMode);
169                         updateStatus(ThingStatus.ONLINE);
170                     } catch (Exception e) {
171                         logger.warn("Failed to send command '{}' for device '{}': {}", command, getThing().getUID(),
172                                 e.getMessage());
173                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
174                     }
175                 }
176                 // if command.equals(OnOffType.OFF) we do nothing because WeMo Coffee Maker cannot be switched
177                 // off remotely
178             }
179         }
180     }
181
182     @Override
183     public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
184         // We can subscribe to GENA events, but there is no usefull response right now.
185     }
186
187     /**
188      * The {@link updateWemoState} polls the actual state of a WeMo CoffeeMaker.
189      */
190     protected void updateWemoState() {
191         String actionService = DEVICEACTION;
192         String wemoURL = getWemoURL(actionService);
193         if (wemoURL == null) {
194             logger.debug("Failed to get actual state for device '{}': URL cannot be created", getThing().getUID());
195             return;
196         }
197         try {
198             String action = "GetAttributes";
199             String soapHeader = "\"urn:Belkin:service:" + actionService + ":1#" + action + "\"";
200             String content = createStateRequestContent(action, actionService);
201             String wemoCallResponse = wemoHttpCaller.executeCall(wemoURL, soapHeader, content);
202             try {
203                 String stringParser = substringBetween(wemoCallResponse, "<attributeList>", "</attributeList>");
204
205                 // Due to Belkins bad response formatting, we need to run this twice.
206                 stringParser = unescapeXml(stringParser);
207                 stringParser = unescapeXml(stringParser);
208
209                 logger.trace("CoffeeMaker response '{}' for device '{}' received", stringParser, getThing().getUID());
210
211                 stringParser = "<data>" + stringParser + "</data>";
212
213                 DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
214                 // see
215                 // https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
216                 dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
217                 dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
218                 dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
219                 dbf.setXIncludeAware(false);
220                 dbf.setExpandEntityReferences(false);
221                 DocumentBuilder db = dbf.newDocumentBuilder();
222                 InputSource is = new InputSource();
223                 is.setCharacterStream(new StringReader(stringParser));
224
225                 Document doc = db.parse(is);
226                 NodeList nodes = doc.getElementsByTagName("attribute");
227
228                 // iterate the attributes
229                 for (int i = 0; i < nodes.getLength(); i++) {
230                     Element element = (Element) nodes.item(i);
231
232                     NodeList deviceIndex = element.getElementsByTagName("name");
233                     Element line = (Element) deviceIndex.item(0);
234                     String attributeName = getCharacterDataFromElement(line);
235                     logger.trace("attributeName: {}", attributeName);
236
237                     NodeList deviceID = element.getElementsByTagName("value");
238                     line = (Element) deviceID.item(0);
239                     String attributeValue = getCharacterDataFromElement(line);
240                     logger.trace("attributeValue: {}", attributeValue);
241
242                     switch (attributeName) {
243                         case "Mode":
244                             State newMode = new StringType("Brewing");
245                             State newAttributeValue;
246
247                             switch (attributeValue) {
248                                 case "0":
249                                     updateState(CHANNEL_STATE, OnOffType.ON);
250                                     newMode = new StringType("Refill");
251                                     updateState(CHANNEL_COFFEE_MODE, newMode);
252                                     break;
253                                 case "1":
254                                     updateState(CHANNEL_STATE, OnOffType.OFF);
255                                     newMode = new StringType("PlaceCarafe");
256                                     updateState(CHANNEL_COFFEE_MODE, newMode);
257                                     break;
258                                 case "2":
259                                     updateState(CHANNEL_STATE, OnOffType.OFF);
260                                     newMode = new StringType("RefillWater");
261                                     updateState(CHANNEL_COFFEE_MODE, newMode);
262                                     break;
263                                 case "3":
264                                     updateState(CHANNEL_STATE, OnOffType.OFF);
265                                     newMode = new StringType("Ready");
266                                     updateState(CHANNEL_COFFEE_MODE, newMode);
267                                     break;
268                                 case "4":
269                                     updateState(CHANNEL_STATE, OnOffType.ON);
270                                     newMode = new StringType("Brewing");
271                                     updateState(CHANNEL_COFFEE_MODE, newMode);
272                                     break;
273                                 case "5":
274                                     updateState(CHANNEL_STATE, OnOffType.OFF);
275                                     newMode = new StringType("Brewed");
276                                     updateState(CHANNEL_COFFEE_MODE, newMode);
277                                     break;
278                                 case "6":
279                                     updateState(CHANNEL_STATE, OnOffType.OFF);
280                                     newMode = new StringType("CleaningBrewing");
281                                     updateState(CHANNEL_COFFEE_MODE, newMode);
282                                     break;
283                                 case "7":
284                                     updateState(CHANNEL_STATE, OnOffType.OFF);
285                                     newMode = new StringType("CleaningSoaking");
286                                     updateState(CHANNEL_COFFEE_MODE, newMode);
287                                     break;
288                                 case "8":
289                                     updateState(CHANNEL_STATE, OnOffType.OFF);
290                                     newMode = new StringType("BrewFailCarafeRemoved");
291                                     updateState(CHANNEL_COFFEE_MODE, newMode);
292                                     break;
293                             }
294                             break;
295                         case "ModeTime":
296                             newAttributeValue = new DecimalType(attributeValue);
297                             updateState(CHANNEL_MODE_TIME, newAttributeValue);
298                             break;
299                         case "TimeRemaining":
300                             newAttributeValue = new DecimalType(attributeValue);
301                             updateState(CHANNEL_TIME_REMAINING, newAttributeValue);
302                             break;
303                         case "WaterLevelReached":
304                             newAttributeValue = new DecimalType(attributeValue);
305                             updateState(CHANNEL_WATER_LEVEL_REACHED, newAttributeValue);
306                             break;
307                         case "CleanAdvise":
308                             newAttributeValue = OnOffType.from(!"0".equals(attributeValue));
309                             updateState(CHANNEL_CLEAN_ADVISE, newAttributeValue);
310                             break;
311                         case "FilterAdvise":
312                             newAttributeValue = OnOffType.from(!"0".equals(attributeValue));
313                             updateState(CHANNEL_FILTER_ADVISE, newAttributeValue);
314                             break;
315                         case "Brewed":
316                             newAttributeValue = getDateTimeState(attributeValue);
317                             if (newAttributeValue != null) {
318                                 updateState(CHANNEL_BREWED, newAttributeValue);
319                             }
320                             break;
321                         case "LastCleaned":
322                             newAttributeValue = getDateTimeState(attributeValue);
323                             if (newAttributeValue != null) {
324                                 updateState(CHANNEL_LAST_CLEANED, newAttributeValue);
325                             }
326                             break;
327                     }
328                 }
329                 updateStatus(ThingStatus.ONLINE);
330             } catch (Exception e) {
331                 logger.warn("Failed to parse attributeList for WeMo CoffeMaker '{}'", this.getThing().getUID(), e);
332             }
333         } catch (Exception e) {
334             logger.warn("Failed to get attributes for device '{}'", getThing().getUID(), e);
335             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
336         }
337     }
338
339     public @Nullable State getDateTimeState(String attributeValue) {
340         long value = 0;
341         try {
342             value = Long.parseLong(attributeValue);
343         } catch (NumberFormatException e) {
344             logger.warn("Unable to parse attributeValue '{}' for device '{}'; expected long", attributeValue,
345                     getThing().getUID());
346             return null;
347         }
348         ZonedDateTime zoned = ZonedDateTime.ofInstant(Instant.ofEpochSecond(value), TimeZone.getDefault().toZoneId());
349         State dateTimeState = new DateTimeType(zoned);
350         logger.trace("New attribute brewed '{}' received", dateTimeState);
351         return dateTimeState;
352     }
353 }