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