]> git.basschouten.com Git - openhab-addons.git/blob
ad2ac3d7671f057e091f755e3aad2a632aa5bcd1
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.miele.internal.handler;
14
15 import static org.openhab.binding.miele.internal.MieleBindingConstants.*;
16
17 import java.io.BufferedInputStream;
18 import java.io.ByteArrayOutputStream;
19 import java.io.IOException;
20 import java.io.InputStream;
21 import java.io.OutputStream;
22 import java.io.StringReader;
23 import java.net.DatagramPacket;
24 import java.net.HttpURLConnection;
25 import java.net.InetAddress;
26 import java.net.MalformedURLException;
27 import java.net.MulticastSocket;
28 import java.net.SocketTimeoutException;
29 import java.net.URL;
30 import java.net.UnknownHostException;
31 import java.util.ArrayList;
32 import java.util.Collections;
33 import java.util.HashMap;
34 import java.util.List;
35 import java.util.Map;
36 import java.util.Random;
37 import java.util.Set;
38 import java.util.concurrent.CopyOnWriteArrayList;
39 import java.util.concurrent.ExecutorService;
40 import java.util.concurrent.Executors;
41 import java.util.concurrent.Future;
42 import java.util.concurrent.ScheduledFuture;
43 import java.util.concurrent.TimeUnit;
44 import java.util.regex.Pattern;
45 import java.util.zip.GZIPInputStream;
46
47 import org.apache.commons.lang3.StringUtils;
48 import org.openhab.core.common.NamedThreadFactory;
49 import org.openhab.core.thing.Bridge;
50 import org.openhab.core.thing.ChannelUID;
51 import org.openhab.core.thing.Thing;
52 import org.openhab.core.thing.ThingStatus;
53 import org.openhab.core.thing.ThingStatusDetail;
54 import org.openhab.core.thing.ThingTypeUID;
55 import org.openhab.core.thing.binding.BaseBridgeHandler;
56 import org.openhab.core.types.Command;
57 import org.openhab.core.types.RefreshType;
58 import org.slf4j.Logger;
59 import org.slf4j.LoggerFactory;
60
61 import com.google.gson.Gson;
62 import com.google.gson.JsonArray;
63 import com.google.gson.JsonElement;
64 import com.google.gson.JsonObject;
65 import com.google.gson.JsonParser;
66
67 /**
68  * The {@link MieleBridgeHandler} is responsible for handling commands, which are
69  * sent to one of the channels.
70  *
71  * @author Karel Goderis - Initial contribution
72  * @author Kai Kreuzer - Fixed lifecycle issues
73  * @author Martin Lepsy - Added protocol information to support WiFi devices & some refactoring for HomeDevice
74  */
75 public class MieleBridgeHandler extends BaseBridgeHandler {
76
77     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_XGW3000);
78
79     private static final Pattern IP_PATTERN = Pattern
80             .compile("^(([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.){3}([01]?\\d\\d?|2[0-4]\\d|25[0-5])$");
81
82     protected static final int POLLING_PERIOD = 15; // in seconds
83     protected static final int JSON_RPC_PORT = 2810;
84     protected static final String JSON_RPC_MULTICAST_IP1 = "239.255.68.139";
85     protected static final String JSON_RPC_MULTICAST_IP2 = "224.255.68.139";
86     private boolean lastBridgeConnectionState = false;
87     private boolean currentBridgeConnectionState = false;
88
89     protected Random rand = new Random();
90     protected Gson gson = new Gson();
91     private final Logger logger = LoggerFactory.getLogger(MieleBridgeHandler.class);
92
93     protected List<ApplianceStatusListener> applianceStatusListeners = new CopyOnWriteArrayList<>();
94     protected ScheduledFuture<?> pollingJob;
95     protected ExecutorService executor;
96     protected Future<?> eventListenerJob;
97
98     protected List<HomeDevice> previousHomeDevices = new CopyOnWriteArrayList<>();
99
100     protected URL url;
101     protected Map<String, String> headers;
102
103     // Data structures to de-JSONify whatever Miele appliances are sending us
104     public class HomeDevice {
105
106         private static final String PROTOCOL_LAN = "LAN";
107
108         public String Name;
109         public String Status;
110         public String ParentUID;
111         public String ProtocolAdapterName;
112         public String Vendor;
113         public String UID;
114         public String Type;
115         public JsonArray DeviceClasses;
116         public String Version;
117         public String TimestampAdded;
118         public JsonObject Error;
119         public JsonObject Properties;
120
121         HomeDevice() {
122         }
123
124         public String getId() {
125             return getApplianceId().replaceAll("[^a-zA-Z0-9_]", "_");
126         }
127
128         public String getProtocol() {
129             return ProtocolAdapterName.equals(PROTOCOL_LAN) ? HDM_LAN : HDM_ZIGBEE;
130         }
131
132         public String getApplianceId() {
133             return ProtocolAdapterName.equals(PROTOCOL_LAN) ? StringUtils.right(UID, UID.length() - HDM_LAN.length())
134                     : StringUtils.right(UID, UID.length() - HDM_ZIGBEE.length());
135         }
136     }
137
138     public class DeviceClassObject {
139         public String DeviceClassType;
140         public JsonArray Operations;
141         public String DeviceClass;
142         public JsonArray Properties;
143
144         DeviceClassObject() {
145         }
146     }
147
148     public class DeviceOperation {
149         public String Name;
150         public String Arguments;
151         public JsonObject Metadata;
152
153         DeviceOperation() {
154         }
155     }
156
157     public class DeviceProperty {
158         public String Name;
159         public String Value;
160         public int Polling;
161         public JsonObject Metadata;
162
163         DeviceProperty() {
164         }
165     }
166
167     public class DeviceMetaData {
168         public String Filter;
169         public String description;
170         public String LocalizedID;
171         public String LocalizedValue;
172         public JsonObject MieleEnum;
173         public String access;
174     }
175
176     public MieleBridgeHandler(Bridge bridge) {
177         super(bridge);
178     }
179
180     @Override
181     public void initialize() {
182         logger.debug("Initializing the Miele bridge handler.");
183
184         if (getConfig().get(HOST) != null && getConfig().get(INTERFACE) != null) {
185             if (IP_PATTERN.matcher((String) getConfig().get(HOST)).matches()
186                     && IP_PATTERN.matcher((String) getConfig().get(INTERFACE)).matches()) {
187                 try {
188                     url = new URL("http://" + (String) getConfig().get(HOST) + "/remote/json-rpc");
189                 } catch (MalformedURLException e) {
190                     logger.debug("An exception occurred while defining an URL :'{}'", e.getMessage());
191                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
192                     return;
193                 }
194
195                 // for future usage - no headers to be set for now
196                 headers = new HashMap<>();
197
198                 onUpdate();
199                 updateStatus(ThingStatus.UNKNOWN);
200             } else {
201                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
202                         "Invalid IP address for the Miele@Home gateway or multicast interface:" + getConfig().get(HOST)
203                                 + "/" + getConfig().get(INTERFACE));
204             }
205         } else {
206             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
207                     "Cannot connect to the Miele gateway. host IP address or multicast interface are not set.");
208         }
209     }
210
211     private Runnable pollingRunnable = new Runnable() {
212         @Override
213         public void run() {
214             if (IP_PATTERN.matcher((String) getConfig().get(HOST)).matches()) {
215                 try {
216                     if (isReachable((String) getConfig().get(HOST))) {
217                         currentBridgeConnectionState = true;
218                     } else {
219                         currentBridgeConnectionState = false;
220                         lastBridgeConnectionState = false;
221                         onConnectionLost();
222                     }
223
224                     if (!lastBridgeConnectionState && currentBridgeConnectionState) {
225                         logger.debug("Connection to Miele Gateway {} established.", getConfig().get(HOST));
226                         lastBridgeConnectionState = true;
227                         onConnectionResumed();
228                     }
229
230                     if (currentBridgeConnectionState) {
231                         if (getThing().getStatus() == ThingStatus.ONLINE) {
232                             List<HomeDevice> currentHomeDevices = getHomeDevices();
233                             for (HomeDevice hd : currentHomeDevices) {
234                                 boolean isExisting = false;
235                                 for (HomeDevice phd : previousHomeDevices) {
236                                     if (phd.UID.equals(hd.UID)) {
237                                         isExisting = true;
238                                         break;
239                                     }
240                                 }
241                                 if (!isExisting) {
242                                     logger.debug("A new appliance with ID '{}' has been added", hd.UID);
243                                     for (ApplianceStatusListener listener : applianceStatusListeners) {
244                                         listener.onApplianceAdded(hd);
245                                     }
246                                 }
247                             }
248
249                             for (HomeDevice hd : previousHomeDevices) {
250                                 boolean isCurrent = false;
251                                 for (HomeDevice chd : currentHomeDevices) {
252                                     if (chd.UID.equals(hd.UID)) {
253                                         isCurrent = true;
254                                         break;
255                                     }
256                                 }
257                                 if (!isCurrent) {
258                                     logger.debug("The appliance with ID '{}' has been removed", hd);
259                                     for (ApplianceStatusListener listener : applianceStatusListeners) {
260                                         listener.onApplianceRemoved(hd);
261                                     }
262                                 }
263                             }
264
265                             previousHomeDevices = currentHomeDevices;
266
267                             for (Thing appliance : getThing().getThings()) {
268                                 if (appliance.getStatus() == ThingStatus.ONLINE) {
269                                     String applianceId = (String) appliance.getConfiguration().getProperties()
270                                             .get(APPLIANCE_ID);
271                                     String protocol = appliance.getProperties().get(PROTOCOL_PROPERTY_NAME);
272                                     if (protocol == null) {
273                                         logger.error("Protocol property is missing for {}", applianceId);
274                                         continue;
275                                     }
276                                     String UID = protocol + applianceId;
277
278                                     Object[] args = new Object[2];
279                                     args[0] = UID;
280                                     args[1] = true;
281                                     JsonElement result = invokeRPC("HDAccess/getDeviceClassObjects", args);
282
283                                     if (result != null) {
284                                         for (JsonElement obj : result.getAsJsonArray()) {
285                                             try {
286                                                 DeviceClassObject dco = gson.fromJson(obj, DeviceClassObject.class);
287
288                                                 for (ApplianceStatusListener listener : applianceStatusListeners) {
289                                                     listener.onApplianceStateChanged(applianceId, dco);
290                                                 }
291                                             } catch (Exception e) {
292                                                 logger.debug("An exception occurred while quering an appliance : '{}'",
293                                                         e.getMessage());
294                                             }
295                                         }
296                                     }
297                                 }
298                             }
299                         }
300                     }
301                 } catch (Exception e) {
302                     logger.debug("An exception occurred while polling an appliance :'{}'", e.getMessage());
303                 }
304             } else {
305                 logger.debug("Invalid IP address for the Miele@Home gateway : '{}'", getConfig().get(HOST));
306             }
307         }
308
309         private boolean isReachable(String ipAddress) {
310             try {
311                 // note that InetAddress.isReachable is unreliable, see
312                 // http://stackoverflow.com/questions/9922543/why-does-inetaddress-isreachable-return-false-when-i-can-ping-the-ip-address
313                 // That's why we do an HTTP access instead
314
315                 // If there is no connection, this line will fail
316                 JsonElement result = invokeRPC("system.listMethods", null);
317                 if (result == null) {
318                     logger.debug("{} is not reachable", ipAddress);
319                     return false;
320                 }
321             } catch (Exception e) {
322                 return false;
323             }
324
325             logger.debug("{} is reachable", ipAddress);
326             return true;
327         }
328     };
329
330     public List<HomeDevice> getHomeDevices() {
331         List<HomeDevice> devices = new ArrayList<>();
332
333         if (getThing().getStatus() == ThingStatus.ONLINE) {
334             try {
335                 String[] args = new String[1];
336                 args[0] = "(type=SuperVision)";
337                 JsonElement result = invokeRPC("HDAccess/getHomeDevices", args);
338
339                 for (JsonElement obj : result.getAsJsonArray()) {
340                     HomeDevice hd = gson.fromJson(obj, HomeDevice.class);
341                     devices.add(hd);
342                 }
343             } catch (Exception e) {
344                 logger.debug("An exception occurred while getting the home devices :'{}'", e.getMessage());
345             }
346         }
347         return devices;
348     }
349
350     private Runnable eventListenerRunnable = () -> {
351         if (IP_PATTERN.matcher((String) getConfig().get(INTERFACE)).matches()) {
352             while (true) {
353                 // Get the address that we are going to connect to.
354                 InetAddress address1 = null;
355                 InetAddress address2 = null;
356                 try {
357                     address1 = InetAddress.getByName(JSON_RPC_MULTICAST_IP1);
358                     address2 = InetAddress.getByName(JSON_RPC_MULTICAST_IP2);
359                 } catch (UnknownHostException e) {
360                     logger.debug("An exception occurred while setting up the multicast receiver : '{}'",
361                             e.getMessage());
362                 }
363
364                 byte[] buf = new byte[256];
365                 MulticastSocket clientSocket = null;
366
367                 while (true) {
368                     try {
369                         clientSocket = new MulticastSocket(JSON_RPC_PORT);
370                         clientSocket.setSoTimeout(100);
371
372                         clientSocket.setInterface(InetAddress.getByName((String) getConfig().get(INTERFACE)));
373                         clientSocket.joinGroup(address1);
374                         clientSocket.joinGroup(address2);
375
376                         while (true) {
377                             try {
378                                 buf = new byte[256];
379                                 DatagramPacket packet = new DatagramPacket(buf, buf.length);
380                                 clientSocket.receive(packet);
381
382                                 String event = new String(packet.getData());
383                                 logger.debug("Received a multicast event '{}' from '{}:{}'", event, packet.getAddress(),
384                                         packet.getPort());
385
386                                 DeviceProperty dp = new DeviceProperty();
387                                 String uid = null;
388
389                                 String[] parts = StringUtils.split(event, "&");
390                                 for (String p : parts) {
391                                     String[] subparts = StringUtils.split(p, "=");
392                                     switch (subparts[0]) {
393                                         case "property": {
394                                             dp.Name = subparts[1];
395                                             break;
396                                         }
397                                         case "value": {
398                                             dp.Value = subparts[1];
399                                             break;
400                                         }
401                                         case "id": {
402                                             uid = subparts[1];
403                                             break;
404                                         }
405                                     }
406                                 }
407
408                                 for (ApplianceStatusListener listener : applianceStatusListeners) {
409                                     listener.onAppliancePropertyChanged(uid, dp);
410                                 }
411                             } catch (SocketTimeoutException e) {
412                                 try {
413                                     Thread.sleep(500);
414                                 } catch (InterruptedException ex) {
415                                     logger.debug("Eventlistener has been interrupted.");
416                                     break;
417                                 }
418                             }
419                         }
420                     } catch (Exception ex) {
421                         logger.debug("An exception occurred while receiving multicast packets : '{}'", ex.getMessage());
422                     }
423
424                     // restart the cycle with a clean slate
425                     try {
426                         if (clientSocket != null) {
427                             clientSocket.leaveGroup(address1);
428                             clientSocket.leaveGroup(address2);
429                         }
430                     } catch (IOException e) {
431                         logger.debug("An exception occurred while leaving multicast group : '{}'", e.getMessage());
432                     }
433                     if (clientSocket != null) {
434                         clientSocket.close();
435                     }
436                 }
437             }
438         } else {
439             logger.debug("Invalid IP address for the multicast interface : '{}'", getConfig().get(INTERFACE));
440         }
441     };
442
443     public JsonElement invokeOperation(String UID, String modelID, String methodName) {
444         return invokeOperation(UID, modelID, methodName, HDM_ZIGBEE);
445     }
446
447     public JsonElement invokeOperation(String UID, String modelID, String methodName, String protocol) {
448         if (getThing().getStatus() == ThingStatus.ONLINE) {
449             Object[] args = new Object[4];
450             args[0] = protocol + UID;
451             args[1] = "com.miele.xgw3000.gateway.hdm.deviceclasses.Miele" + modelID;
452             args[2] = methodName;
453             args[3] = null;
454             return invokeRPC("HDAccess/invokeDCOOperation", args);
455         } else {
456             logger.debug("The Bridge is offline - operations can not be invoked.");
457             return null;
458         }
459     }
460
461     protected JsonElement invokeRPC(String methodName, Object[] args) {
462         int id = rand.nextInt(Integer.MAX_VALUE);
463
464         JsonObject req = new JsonObject();
465         req.addProperty("jsonrpc", "2.0");
466         req.addProperty("id", id);
467         req.addProperty("method", methodName);
468
469         JsonElement result = null;
470
471         JsonArray params = new JsonArray();
472         if (args != null) {
473             for (Object o : args) {
474                 params.add(gson.toJsonTree(o));
475             }
476         }
477         req.add("params", params);
478
479         String requestData = req.toString();
480         String responseData = null;
481         try {
482             responseData = post(url, headers, requestData);
483         } catch (Exception e) {
484             logger.debug("An exception occurred while posting data : '{}'", e.getMessage());
485         }
486
487         if (responseData != null) {
488             logger.debug("The request '{}' yields '{}'", requestData, responseData);
489             JsonObject resp = (JsonObject) JsonParser.parseReader(new StringReader(responseData));
490
491             result = resp.get("result");
492             JsonElement error = resp.get("error");
493
494             if (error != null && !error.isJsonNull()) {
495                 if (error.isJsonPrimitive()) {
496                     logger.debug("A remote exception occurred: '{}'", error.getAsString());
497                 } else if (error.isJsonObject()) {
498                     JsonObject o = error.getAsJsonObject();
499                     Integer code = (o.has("code") ? o.get("code").getAsInt() : null);
500                     String message = (o.has("message") ? o.get("message").getAsString() : null);
501                     String data = (o.has("data") ? (o.get("data") instanceof JsonObject ? o.get("data").toString()
502                             : o.get("data").getAsString()) : null);
503                     logger.debug("A remote exception occurred: '{}':'{}':'{}'", code, message, data);
504                 } else {
505                     logger.debug("An unknown remote exception occurred: '{}'", error.toString());
506                 }
507             }
508         }
509
510         return result;
511     }
512
513     protected String post(URL url, Map<String, String> headers, String data) throws IOException {
514         HttpURLConnection connection = (HttpURLConnection) url.openConnection();
515
516         if (headers != null) {
517             for (Map.Entry<String, String> entry : headers.entrySet()) {
518                 connection.addRequestProperty(entry.getKey(), entry.getValue());
519             }
520         }
521
522         connection.addRequestProperty("Accept-Encoding", "gzip");
523
524         connection.setRequestMethod("POST");
525         connection.setDoOutput(true);
526         connection.connect();
527
528         OutputStream out = null;
529
530         try {
531             out = connection.getOutputStream();
532
533             out.write(data.getBytes());
534             out.flush();
535
536             int statusCode = connection.getResponseCode();
537             if (statusCode != HttpURLConnection.HTTP_OK) {
538                 logger.debug("An unexpected status code was returned: '{}'", statusCode);
539             }
540         } finally {
541             if (out != null) {
542                 out.close();
543             }
544         }
545
546         String responseEncoding = connection.getHeaderField("Content-Encoding");
547         responseEncoding = (responseEncoding == null ? "" : responseEncoding.trim());
548
549         ByteArrayOutputStream bos = new ByteArrayOutputStream();
550
551         InputStream in = connection.getInputStream();
552         try {
553             in = connection.getInputStream();
554             if ("gzip".equalsIgnoreCase(responseEncoding)) {
555                 in = new GZIPInputStream(in);
556             }
557             in = new BufferedInputStream(in);
558
559             byte[] buff = new byte[1024];
560             int n;
561             while ((n = in.read(buff)) > 0) {
562                 bos.write(buff, 0, n);
563             }
564             bos.flush();
565             bos.close();
566         } finally {
567             if (in != null) {
568                 in.close();
569             }
570         }
571
572         return bos.toString();
573     }
574
575     private synchronized void onUpdate() {
576         logger.debug("Scheduling the Miele polling job");
577         if (pollingJob == null || pollingJob.isCancelled()) {
578             logger.trace("Scheduling the Miele polling job period is {}", POLLING_PERIOD);
579             pollingJob = scheduler.scheduleWithFixedDelay(pollingRunnable, 0, POLLING_PERIOD, TimeUnit.SECONDS);
580             logger.trace("Scheduling the Miele polling job Job is done ?{}", pollingJob.isDone());
581         }
582         logger.debug("Scheduling the Miele event listener job");
583
584         if (eventListenerJob == null || eventListenerJob.isCancelled()) {
585             executor = Executors.newSingleThreadExecutor(new NamedThreadFactory("binding-miele"));
586             eventListenerJob = executor.submit(eventListenerRunnable);
587         }
588     }
589
590     /**
591      * This method is called whenever the connection to the given {@link MieleBridge} is lost.
592      *
593      */
594     public void onConnectionLost() {
595         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
596     }
597
598     /**
599      * This method is called whenever the connection to the given {@link MieleBridge} is resumed.
600      *
601      * @param bridge the hue bridge the connection is resumed to
602      */
603     public void onConnectionResumed() {
604         updateStatus(ThingStatus.ONLINE);
605         for (Thing thing : getThing().getThings()) {
606             MieleApplianceHandler<?> handler = (MieleApplianceHandler<?>) thing.getHandler();
607             if (handler != null) {
608                 handler.onBridgeConnectionResumed();
609             }
610         }
611     }
612
613     public boolean registerApplianceStatusListener(ApplianceStatusListener applianceStatusListener) {
614         if (applianceStatusListener == null) {
615             throw new IllegalArgumentException("It's not allowed to pass a null ApplianceStatusListener.");
616         }
617         boolean result = applianceStatusListeners.add(applianceStatusListener);
618         if (result && isInitialized()) {
619             onUpdate();
620
621             for (HomeDevice hd : getHomeDevices()) {
622                 applianceStatusListener.onApplianceAdded(hd);
623             }
624         }
625         return result;
626     }
627
628     public boolean unregisterApplianceStatusListener(ApplianceStatusListener applianceStatusListener) {
629         boolean result = applianceStatusListeners.remove(applianceStatusListener);
630         if (result && isInitialized()) {
631             onUpdate();
632         }
633         return result;
634     }
635
636     @Override
637     public void handleCommand(ChannelUID channelUID, Command command) {
638         // Nothing to do here - the XGW bridge does not handle commands, for now
639         if (command instanceof RefreshType) {
640             // Placeholder for future refinement
641             return;
642         }
643     }
644
645     @Override
646     public void dispose() {
647         super.dispose();
648         if (pollingJob != null) {
649             pollingJob.cancel(true);
650             pollingJob = null;
651         }
652         if (eventListenerJob != null) {
653             eventListenerJob.cancel(true);
654             eventListenerJob = null;
655         }
656         if (executor != null) {
657             executor.shutdownNow();
658             executor = null;
659         }
660     }
661 }