]> git.basschouten.com Git - openhab-addons.git/blob
cf385ed7b0d7567eee5b1837f385e8550f6d74a7
[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.miele.internal.handler;
14
15 import static org.openhab.binding.miele.internal.MieleBindingConstants.*;
16
17 import java.io.IOException;
18 import java.net.DatagramPacket;
19 import java.net.InetAddress;
20 import java.net.MalformedURLException;
21 import java.net.MulticastSocket;
22 import java.net.SocketTimeoutException;
23 import java.net.UnknownHostException;
24 import java.util.ArrayList;
25 import java.util.IllformedLocaleException;
26 import java.util.Iterator;
27 import java.util.List;
28 import java.util.Locale;
29 import java.util.Map;
30 import java.util.Map.Entry;
31 import java.util.Set;
32 import java.util.concurrent.ConcurrentHashMap;
33 import java.util.concurrent.ExecutorService;
34 import java.util.concurrent.Executors;
35 import java.util.concurrent.Future;
36 import java.util.concurrent.ScheduledFuture;
37 import java.util.concurrent.TimeUnit;
38 import java.util.regex.Pattern;
39
40 import org.eclipse.jdt.annotation.NonNullByDefault;
41 import org.eclipse.jdt.annotation.Nullable;
42 import org.openhab.binding.miele.internal.FullyQualifiedApplianceIdentifier;
43 import org.openhab.binding.miele.internal.MieleGatewayCommunicationController;
44 import org.openhab.binding.miele.internal.api.dto.DeviceClassObject;
45 import org.openhab.binding.miele.internal.api.dto.DeviceProperty;
46 import org.openhab.binding.miele.internal.api.dto.HomeDevice;
47 import org.openhab.binding.miele.internal.exceptions.MieleRpcException;
48 import org.openhab.core.common.NamedThreadFactory;
49 import org.openhab.core.config.core.Configuration;
50 import org.openhab.core.thing.Bridge;
51 import org.openhab.core.thing.ChannelUID;
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.JsonElement;
63
64 /**
65  * The {@link MieleBridgeHandler} is responsible for handling commands, which are
66  * sent to one of the channels.
67  *
68  * @author Karel Goderis - Initial contribution
69  * @author Kai Kreuzer - Fixed lifecycle issues
70  * @author Martin Lepsy - Added protocol information to support WiFi devices & some refactoring for HomeDevice
71  * @author Jacob Laursen - Fixed multicast and protocol support (ZigBee/LAN)
72  **/
73 @NonNullByDefault
74 public class MieleBridgeHandler extends BaseBridgeHandler {
75
76     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_XGW3000);
77
78     private static final Pattern IP_PATTERN = Pattern
79             .compile("^(([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.){3}([01]?\\d\\d?|2[0-4]\\d|25[0-5])$");
80
81     private static final int POLLING_PERIOD = 15; // in seconds
82     private static final int JSON_RPC_PORT = 2810;
83     private static final String JSON_RPC_MULTICAST_IP1 = "239.255.68.139";
84     private static final String JSON_RPC_MULTICAST_IP2 = "224.255.68.139";
85
86     private final Logger logger = LoggerFactory.getLogger(MieleBridgeHandler.class);
87
88     private boolean lastBridgeConnectionState = false;
89
90     private Gson gson = new Gson();
91     private @NonNullByDefault({}) MieleGatewayCommunicationController gatewayCommunication;
92
93     private Set<DiscoveryListener> discoveryListeners = ConcurrentHashMap.newKeySet();
94     private Map<String, ApplianceStatusListener> applianceStatusListeners = new ConcurrentHashMap<>();
95     private @Nullable ScheduledFuture<?> pollingJob;
96     private @Nullable ExecutorService executor;
97     private @Nullable Future<?> eventListenerJob;
98
99     private Map<String, HomeDevice> cachedHomeDevicesByApplianceId = new ConcurrentHashMap<>();
100     private Map<String, HomeDevice> cachedHomeDevicesByRemoteUid = new ConcurrentHashMap<>();
101
102     public MieleBridgeHandler(Bridge bridge) {
103         super(bridge);
104     }
105
106     @Override
107     public void initialize() {
108         logger.debug("Initializing the Miele bridge handler.");
109
110         if (!validateConfig(getConfig())) {
111             return;
112         }
113
114         try {
115             gatewayCommunication = new MieleGatewayCommunicationController((String) getConfig().get(HOST));
116         } catch (MalformedURLException e) {
117             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
118             return;
119         }
120
121         updateStatus(ThingStatus.UNKNOWN);
122         lastBridgeConnectionState = false;
123         schedulePollingAndEventListener();
124     }
125
126     private boolean validateConfig(Configuration config) {
127         if (config.get(HOST) == null || ((String) config.get(HOST)).isBlank()) {
128             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
129                     "@text/offline.configuration-error.ip-address-not-set");
130             return false;
131         }
132         if (config.get(INTERFACE) == null || ((String) config.get(INTERFACE)).isBlank()) {
133             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
134                     "@text/offline.configuration-error.ip-multicast-interface-not-set");
135             return false;
136         }
137         if (!IP_PATTERN.matcher((String) config.get(HOST)).matches()) {
138             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
139                     "@text/offline.configuration-error.invalid-ip-gateway [\"" + config.get(HOST) + "\"]");
140             return false;
141         }
142         if (!IP_PATTERN.matcher((String) config.get(INTERFACE)).matches()) {
143             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
144                     "@text/offline.configuration-error.invalid-ip-multicast-interface [\"" + config.get(INTERFACE)
145                             + "\"]");
146             return false;
147         }
148         String language = (String) config.get(LANGUAGE);
149         if (language != null && !language.isBlank()) {
150             try {
151                 new Locale.Builder().setLanguageTag(language).build();
152             } catch (IllformedLocaleException e) {
153                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
154                         "@text/offline.configuration-error.invalid-language [\"" + language + "\"]");
155                 return false;
156             }
157         }
158         return true;
159     }
160
161     private Runnable pollingRunnable = new Runnable() {
162         @Override
163         public void run() {
164             String host = (String) getConfig().get(HOST);
165             try {
166                 List<HomeDevice> homeDevices = getHomeDevices();
167
168                 if (!lastBridgeConnectionState) {
169                     logger.debug("Connection to Miele Gateway {} established.", host);
170                     lastBridgeConnectionState = true;
171                 }
172                 updateStatus(ThingStatus.ONLINE);
173
174                 refreshHomeDevices(homeDevices);
175
176                 for (Entry<String, ApplianceStatusListener> entry : applianceStatusListeners.entrySet()) {
177                     String applianceId = entry.getKey();
178                     ApplianceStatusListener listener = entry.getValue();
179                     FullyQualifiedApplianceIdentifier applianceIdentifier = getApplianceIdentifierFromApplianceId(
180                             applianceId);
181                     if (applianceIdentifier == null) {
182                         logger.debug("The appliance with ID '{}' was not found in appliance list from bridge.",
183                                 applianceId);
184                         listener.onApplianceRemoved();
185                         continue;
186                     }
187
188                     Object[] args = new Object[2];
189                     args[0] = applianceIdentifier.getUid();
190                     args[1] = true;
191                     JsonElement result = gatewayCommunication.invokeRPC("HDAccess/getDeviceClassObjects", args);
192
193                     for (JsonElement obj : result.getAsJsonArray()) {
194                         try {
195                             DeviceClassObject dco = gson.fromJson(obj, DeviceClassObject.class);
196
197                             // Skip com.prosyst.mbs.services.zigbee.hdm.deviceclasses.ReportingControl
198                             if (dco == null || !dco.DeviceClass.startsWith(MIELE_CLASS)) {
199                                 continue;
200                             }
201
202                             listener.onApplianceStateChanged(dco);
203                         } catch (Exception e) {
204                             logger.debug("An exception occurred while querying an appliance : '{}'", e.getMessage());
205                         }
206                     }
207                 }
208             } catch (MieleRpcException e) {
209                 Throwable cause = e.getCause();
210                 if (cause == null) {
211                     logger.debug("An exception occurred while polling an appliance: '{}'", e.getMessage());
212                 } else {
213                     logger.debug("An exception occurred while polling an appliance: '{}' -> '{}'", e.getMessage(),
214                             cause.getMessage());
215                 }
216                 if (lastBridgeConnectionState) {
217                     logger.debug("Connection to Miele Gateway {} lost.", host);
218                     lastBridgeConnectionState = false;
219                 }
220                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
221             }
222         }
223     };
224
225     private synchronized void refreshHomeDevices(List<HomeDevice> homeDevices) {
226         for (HomeDevice hd : homeDevices) {
227             String key = hd.getApplianceIdentifier().getApplianceId();
228             if (!cachedHomeDevicesByApplianceId.containsKey(key)) {
229                 logger.debug("A new appliance with ID '{}' has been added", hd.UID);
230                 for (DiscoveryListener listener : discoveryListeners) {
231                     listener.onApplianceAdded(hd);
232                 }
233                 ApplianceStatusListener listener = applianceStatusListeners
234                         .get(hd.getApplianceIdentifier().getApplianceId());
235                 if (listener != null) {
236                     listener.onApplianceAdded(hd);
237                 }
238             }
239             cachedHomeDevicesByApplianceId.put(key, hd);
240             cachedHomeDevicesByRemoteUid.put(hd.getRemoteUid(), hd);
241         }
242
243         Set<Entry<String, HomeDevice>> cachedEntries = cachedHomeDevicesByApplianceId.entrySet();
244         Iterator<Entry<String, HomeDevice>> iterator = cachedEntries.iterator();
245
246         while (iterator.hasNext()) {
247             Entry<String, HomeDevice> cachedEntry = iterator.next();
248             HomeDevice cachedHomeDevice = cachedEntry.getValue();
249             if (!homeDevices.stream().anyMatch(d -> d.UID.equals(cachedHomeDevice.UID))) {
250                 logger.debug("The appliance with ID '{}' has been removed", cachedHomeDevice.UID);
251                 for (DiscoveryListener listener : discoveryListeners) {
252                     listener.onApplianceRemoved(cachedHomeDevice);
253                 }
254                 ApplianceStatusListener listener = applianceStatusListeners
255                         .get(cachedHomeDevice.getApplianceIdentifier().getApplianceId());
256                 if (listener != null) {
257                     listener.onApplianceRemoved();
258                 }
259                 cachedHomeDevicesByRemoteUid.remove(cachedHomeDevice.getRemoteUid());
260                 iterator.remove();
261             }
262         }
263     }
264
265     public List<HomeDevice> getHomeDevicesEmptyOnFailure() {
266         try {
267             return getHomeDevices();
268         } catch (MieleRpcException e) {
269             Throwable cause = e.getCause();
270             if (cause == null) {
271                 logger.debug("An exception occurred while getting the home devices: '{}'", e.getMessage());
272             } else {
273                 logger.debug("An exception occurred while getting the home devices: '{}' -> '{}", e.getMessage(),
274                         cause.getMessage());
275             }
276             return new ArrayList<>();
277         }
278     }
279
280     private List<HomeDevice> getHomeDevices() throws MieleRpcException {
281         List<HomeDevice> devices = new ArrayList<>();
282
283         if (!isInitialized()) {
284             return devices;
285         }
286
287         String[] args = new String[1];
288         args[0] = "(type=SuperVision)";
289         JsonElement result = gatewayCommunication.invokeRPC("HDAccess/getHomeDevices", args);
290
291         for (JsonElement obj : result.getAsJsonArray()) {
292             HomeDevice hd = gson.fromJson(obj, HomeDevice.class);
293             if (hd != null) {
294                 devices.add(hd);
295             }
296         }
297         return devices;
298     }
299
300     private @Nullable FullyQualifiedApplianceIdentifier getApplianceIdentifierFromApplianceId(String applianceId) {
301         HomeDevice homeDevice = this.cachedHomeDevicesByApplianceId.get(applianceId);
302         if (homeDevice == null) {
303             return null;
304         }
305
306         return homeDevice.getApplianceIdentifier();
307     }
308
309     private Runnable eventListenerRunnable = () -> {
310         if (IP_PATTERN.matcher((String) getConfig().get(INTERFACE)).matches()) {
311             while (true) {
312                 // Get the address that we are going to connect to.
313                 InetAddress address1 = null;
314                 InetAddress address2 = null;
315                 try {
316                     address1 = InetAddress.getByName(JSON_RPC_MULTICAST_IP1);
317                     address2 = InetAddress.getByName(JSON_RPC_MULTICAST_IP2);
318                 } catch (UnknownHostException e) {
319                     logger.debug("An exception occurred while setting up the multicast receiver: '{}'", e.getMessage());
320                 }
321
322                 byte[] buf = new byte[256];
323                 MulticastSocket clientSocket = null;
324
325                 while (true) {
326                     try {
327                         clientSocket = new MulticastSocket(JSON_RPC_PORT);
328                         clientSocket.setSoTimeout(100);
329
330                         clientSocket.setInterface(InetAddress.getByName((String) getConfig().get(INTERFACE)));
331                         clientSocket.joinGroup(address1);
332                         clientSocket.joinGroup(address2);
333
334                         while (true) {
335                             try {
336                                 buf = new byte[256];
337                                 DatagramPacket packet = new DatagramPacket(buf, buf.length);
338                                 clientSocket.receive(packet);
339
340                                 String event = new String(packet.getData());
341                                 logger.debug("Received a multicast event '{}' from '{}:{}'", event, packet.getAddress(),
342                                         packet.getPort());
343
344                                 String[] parts = event.split("&");
345                                 String id = null, name = null, value = null;
346                                 for (String p : parts) {
347                                     String[] subparts = p.split("=");
348                                     switch (subparts[0]) {
349                                         case "property": {
350                                             name = subparts[1];
351                                             break;
352                                         }
353                                         case "value": {
354                                             value = subparts[1].strip().trim();
355                                             break;
356                                         }
357                                         case "id": {
358                                             id = subparts[1];
359                                             break;
360                                         }
361                                     }
362                                 }
363
364                                 if (id == null || name == null || value == null) {
365                                     continue;
366                                 }
367
368                                 // In XGW 3000 firmware 2.03 this was changed from UID (hdm:ZigBee:0123456789abcdef#210)
369                                 // to serial number (001234567890)
370                                 FullyQualifiedApplianceIdentifier applianceIdentifier;
371                                 if (id.startsWith("hdm:")) {
372                                     applianceIdentifier = new FullyQualifiedApplianceIdentifier(id);
373                                 } else {
374                                     HomeDevice device = cachedHomeDevicesByRemoteUid.get(id);
375                                     if (device == null) {
376                                         logger.debug("Multicast event not handled as id {} is unknown.", id);
377                                         continue;
378                                     }
379                                     applianceIdentifier = device.getApplianceIdentifier();
380                                 }
381                                 var deviceProperty = new DeviceProperty();
382                                 deviceProperty.Name = name;
383                                 deviceProperty.Value = value;
384                                 ApplianceStatusListener listener = applianceStatusListeners
385                                         .get(applianceIdentifier.getApplianceId());
386                                 if (listener != null) {
387                                     listener.onAppliancePropertyChanged(deviceProperty);
388                                 }
389                             } catch (SocketTimeoutException e) {
390                                 try {
391                                     Thread.sleep(500);
392                                 } catch (InterruptedException ex) {
393                                     logger.debug("Event listener has been interrupted.");
394                                     break;
395                                 }
396                             }
397                         }
398                     } catch (Exception ex) {
399                         logger.debug("An exception occurred while receiving multicast packets: '{}'", ex.getMessage());
400                     }
401
402                     // restart the cycle with a clean slate
403                     try {
404                         if (clientSocket != null) {
405                             clientSocket.leaveGroup(address1);
406                             clientSocket.leaveGroup(address2);
407                         }
408                     } catch (IOException e) {
409                         logger.debug("An exception occurred while leaving multicast group: '{}'", e.getMessage());
410                     }
411                     if (clientSocket != null) {
412                         clientSocket.close();
413                     }
414                 }
415             }
416         } else {
417             logger.debug("Invalid IP address for the multicast interface: '{}'", getConfig().get(INTERFACE));
418         }
419     };
420
421     public JsonElement invokeOperation(String applianceId, String modelID, String methodName) throws MieleRpcException {
422         if (getThing().getStatus() != ThingStatus.ONLINE) {
423             throw new MieleRpcException("Bridge is offline, operations can not be invoked");
424         }
425
426         FullyQualifiedApplianceIdentifier applianceIdentifier = getApplianceIdentifierFromApplianceId(applianceId);
427         if (applianceIdentifier == null) {
428             throw new MieleRpcException("Appliance with ID" + applianceId
429                     + " was not found in appliance list from gateway - operations can not be invoked");
430         }
431
432         return gatewayCommunication.invokeOperation(applianceIdentifier, modelID, methodName);
433     }
434
435     private synchronized void schedulePollingAndEventListener() {
436         logger.debug("Scheduling the Miele polling job");
437         ScheduledFuture<?> pollingJob = this.pollingJob;
438         if (pollingJob == null || pollingJob.isCancelled()) {
439             logger.trace("Scheduling the Miele polling job period is {}", POLLING_PERIOD);
440             pollingJob = scheduler.scheduleWithFixedDelay(pollingRunnable, 0, POLLING_PERIOD, TimeUnit.SECONDS);
441             this.pollingJob = pollingJob;
442             logger.trace("Scheduling the Miele polling job Job is done ?{}", pollingJob.isDone());
443         }
444
445         logger.debug("Scheduling the Miele event listener job");
446         Future<?> eventListenerJob = this.eventListenerJob;
447         if (eventListenerJob == null || eventListenerJob.isCancelled()) {
448             ExecutorService executor = Executors
449                     .newSingleThreadExecutor(new NamedThreadFactory("binding-" + BINDING_ID));
450             this.executor = executor;
451             this.eventListenerJob = executor.submit(eventListenerRunnable);
452         }
453     }
454
455     public boolean registerApplianceStatusListener(String applianceId,
456             ApplianceStatusListener applianceStatusListener) {
457         ApplianceStatusListener existingListener = applianceStatusListeners.get(applianceId);
458         if (existingListener != null) {
459             if (!existingListener.equals(applianceStatusListener)) {
460                 logger.warn("Unsupported configuration: appliance with ID '{}' referenced by multiple things",
461                         applianceId);
462             } else {
463                 logger.debug("Duplicate listener registration attempted for '{}'", applianceId);
464             }
465             return false;
466         }
467         applianceStatusListeners.put(applianceId, applianceStatusListener);
468
469         HomeDevice cachedHomeDevice = cachedHomeDevicesByApplianceId.get(applianceId);
470         if (cachedHomeDevice != null) {
471             applianceStatusListener.onApplianceAdded(cachedHomeDevice);
472         } else {
473             try {
474                 refreshHomeDevices(getHomeDevices());
475             } catch (MieleRpcException e) {
476                 Throwable cause = e.getCause();
477                 if (cause == null) {
478                     logger.debug("An exception occurred while getting the home devices: '{}'", e.getMessage());
479                 } else {
480                     logger.debug("An exception occurred while getting the home devices: '{}' -> '{}", e.getMessage(),
481                             cause.getMessage());
482                 }
483             }
484         }
485
486         return true;
487     }
488
489     public boolean unregisterApplianceStatusListener(String applianceId,
490             ApplianceStatusListener applianceStatusListener) {
491         return applianceStatusListeners.remove(applianceId) != null;
492     }
493
494     public boolean registerDiscoveryListener(DiscoveryListener discoveryListener) {
495         if (!discoveryListeners.add(discoveryListener)) {
496             return false;
497         }
498         if (cachedHomeDevicesByApplianceId.isEmpty()) {
499             try {
500                 refreshHomeDevices(getHomeDevices());
501             } catch (MieleRpcException e) {
502                 Throwable cause = e.getCause();
503                 if (cause == null) {
504                     logger.debug("An exception occurred while getting the home devices: '{}'", e.getMessage());
505                 } else {
506                     logger.debug("An exception occurred while getting the home devices: '{}' -> '{}", e.getMessage(),
507                             cause.getMessage());
508                 }
509             }
510         } else {
511             for (Entry<String, HomeDevice> entry : cachedHomeDevicesByApplianceId.entrySet()) {
512                 discoveryListener.onApplianceAdded(entry.getValue());
513             }
514         }
515         return true;
516     }
517
518     public boolean unregisterDiscoveryListener(DiscoveryListener discoveryListener) {
519         return discoveryListeners.remove(discoveryListener);
520     }
521
522     @Override
523     public void handleCommand(ChannelUID channelUID, Command command) {
524         // Nothing to do here - the XGW bridge does not handle commands, for now
525         if (command instanceof RefreshType) {
526             // Placeholder for future refinement
527             return;
528         }
529     }
530
531     @Override
532     public void dispose() {
533         super.dispose();
534         ScheduledFuture<?> pollingJob = this.pollingJob;
535         if (pollingJob != null) {
536             pollingJob.cancel(true);
537             this.pollingJob = null;
538         }
539         Future<?> eventListenerJob = this.eventListenerJob;
540         if (eventListenerJob != null) {
541             eventListenerJob.cancel(true);
542             this.eventListenerJob = null;
543         }
544         ExecutorService executor = this.executor;
545         if (executor != null) {
546             executor.shutdownNow();
547             this.executor = null;
548         }
549     }
550 }