2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.miele.internal.handler;
15 import static org.openhab.binding.miele.internal.MieleBindingConstants.*;
17 import java.io.IOException;
18 import java.net.DatagramPacket;
19 import java.net.InetAddress;
20 import java.net.InetSocketAddress;
21 import java.net.InterfaceAddress;
22 import java.net.MulticastSocket;
23 import java.net.NetworkInterface;
24 import java.net.SocketException;
25 import java.net.SocketTimeoutException;
26 import java.net.URISyntaxException;
27 import java.net.UnknownHostException;
28 import java.nio.charset.StandardCharsets;
29 import java.util.ArrayList;
30 import java.util.Enumeration;
31 import java.util.IllformedLocaleException;
32 import java.util.Iterator;
33 import java.util.List;
34 import java.util.Locale;
36 import java.util.Map.Entry;
38 import java.util.concurrent.ConcurrentHashMap;
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;
46 import org.eclipse.jdt.annotation.NonNullByDefault;
47 import org.eclipse.jdt.annotation.Nullable;
48 import org.eclipse.jetty.client.HttpClient;
49 import org.openhab.binding.miele.internal.FullyQualifiedApplianceIdentifier;
50 import org.openhab.binding.miele.internal.MieleGatewayCommunicationController;
51 import org.openhab.binding.miele.internal.api.dto.DeviceClassObject;
52 import org.openhab.binding.miele.internal.api.dto.DeviceProperty;
53 import org.openhab.binding.miele.internal.api.dto.HomeDevice;
54 import org.openhab.binding.miele.internal.exceptions.MieleRpcException;
55 import org.openhab.core.common.NamedThreadFactory;
56 import org.openhab.core.config.core.Configuration;
57 import org.openhab.core.thing.Bridge;
58 import org.openhab.core.thing.ChannelUID;
59 import org.openhab.core.thing.ThingStatus;
60 import org.openhab.core.thing.ThingStatusDetail;
61 import org.openhab.core.thing.ThingTypeUID;
62 import org.openhab.core.thing.binding.BaseBridgeHandler;
63 import org.openhab.core.types.Command;
64 import org.openhab.core.types.RefreshType;
65 import org.slf4j.Logger;
66 import org.slf4j.LoggerFactory;
68 import com.google.gson.Gson;
69 import com.google.gson.JsonElement;
72 * The {@link MieleBridgeHandler} is responsible for handling commands, which are
73 * sent to one of the channels.
75 * @author Karel Goderis - Initial contribution
76 * @author Kai Kreuzer - Fixed lifecycle issues
77 * @author Martin Lepsy - Added protocol information to support WiFi devices and some refactoring for HomeDevice
78 * @author Jacob Laursen - Fixed multicast and protocol support (Zigbee/LAN)
81 public class MieleBridgeHandler extends BaseBridgeHandler {
83 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_XGW3000);
85 private static final Pattern IP_PATTERN = Pattern
86 .compile("^(([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.){3}([01]?\\d\\d?|2[0-4]\\d|25[0-5])$");
88 private static final int POLLING_PERIOD_SECONDS = 15;
89 private static final int JSON_RPC_PORT = 2810;
90 private static final String JSON_RPC_MULTICAST_IP1 = "239.255.68.139";
91 private static final String JSON_RPC_MULTICAST_IP2 = "224.255.68.139";
92 private static final int MULTICAST_TIMEOUT_MILLIS = 100;
93 private static final int MULTICAST_SLEEP_MILLIS = 500;
95 private final Logger logger = LoggerFactory.getLogger(MieleBridgeHandler.class);
97 private boolean lastBridgeConnectionState = false;
99 private final HttpClient httpClient;
100 private final Gson gson = new Gson();
101 private @NonNullByDefault({}) MieleGatewayCommunicationController gatewayCommunication;
103 private Set<DiscoveryListener> discoveryListeners = ConcurrentHashMap.newKeySet();
104 private Map<String, ApplianceStatusListener> applianceStatusListeners = new ConcurrentHashMap<>();
105 private @Nullable ScheduledFuture<?> pollingJob;
106 private @Nullable ExecutorService executor;
107 private @Nullable Future<?> eventListenerJob;
109 private Map<String, HomeDevice> cachedHomeDevicesByApplianceId = new ConcurrentHashMap<>();
110 private Map<String, HomeDevice> cachedHomeDevicesByRemoteUid = new ConcurrentHashMap<>();
112 public MieleBridgeHandler(Bridge bridge, HttpClient httpClient) {
114 this.httpClient = httpClient;
118 public void initialize() {
119 logger.debug("Initializing handler for bridge {}", getThing().getUID());
121 if (!validateConfig(getConfig())) {
126 gatewayCommunication = new MieleGatewayCommunicationController(httpClient, (String) getConfig().get(HOST));
127 } catch (URISyntaxException e) {
128 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
132 updateStatus(ThingStatus.UNKNOWN);
133 lastBridgeConnectionState = false;
134 schedulePollingAndEventListener();
137 private boolean validateConfig(Configuration config) {
138 if (config.get(HOST) == null || ((String) config.get(HOST)).isBlank()) {
139 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
140 "@text/offline.configuration-error.ip-address-not-set");
143 if (config.get(INTERFACE) == null || ((String) config.get(INTERFACE)).isBlank()) {
144 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
145 "@text/offline.configuration-error.ip-multicast-interface-not-set");
148 if (!IP_PATTERN.matcher((String) config.get(INTERFACE)).matches()) {
149 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
150 "@text/offline.configuration-error.invalid-ip-multicast-interface [\"" + config.get(INTERFACE)
154 String language = (String) config.get(LANGUAGE);
155 if (language != null && !language.isBlank()) {
157 new Locale.Builder().setLanguageTag(language).build();
158 } catch (IllformedLocaleException e) {
159 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
160 "@text/offline.configuration-error.invalid-language [\"" + language + "\"]");
167 private Runnable pollingRunnable = new Runnable() {
170 String host = (String) getConfig().get(HOST);
172 List<HomeDevice> homeDevices = getHomeDevices();
174 if (!lastBridgeConnectionState) {
175 logger.debug("Connection to Miele Gateway {} established.", host);
176 lastBridgeConnectionState = true;
178 updateStatus(ThingStatus.ONLINE);
180 refreshHomeDevices(homeDevices);
182 for (Entry<String, ApplianceStatusListener> entry : applianceStatusListeners.entrySet()) {
183 String applianceId = entry.getKey();
184 ApplianceStatusListener listener = entry.getValue();
185 FullyQualifiedApplianceIdentifier applianceIdentifier = getApplianceIdentifierFromApplianceId(
187 if (applianceIdentifier == null) {
188 logger.debug("The appliance with ID '{}' was not found in appliance list from bridge.",
190 listener.onApplianceRemoved();
194 Object[] args = new Object[2];
195 args[0] = applianceIdentifier.getUid();
197 JsonElement result = gatewayCommunication.invokeRPC("HDAccess/getDeviceClassObjects", args);
199 for (JsonElement obj : result.getAsJsonArray()) {
201 DeviceClassObject dco = gson.fromJson(obj, DeviceClassObject.class);
203 // Skip com.prosyst.mbs.services.zigbee.hdm.deviceclasses.ReportingControl
204 if (dco == null || !dco.DeviceClass.startsWith(MIELE_CLASS)) {
208 listener.onApplianceStateChanged(dco);
209 } catch (Exception e) {
210 logger.debug("An exception occurred while querying an appliance : '{}'", e.getMessage());
214 } catch (MieleRpcException e) {
215 Throwable cause = e.getCause();
218 message = e.getMessage();
219 logger.debug("An exception occurred while polling an appliance: '{}'", message);
221 message = cause.getMessage();
222 logger.debug("An exception occurred while polling an appliance: '{}' -> '{}'", e.getMessage(),
225 if (lastBridgeConnectionState) {
226 logger.debug("Connection to Miele Gateway {} lost.", host);
227 lastBridgeConnectionState = false;
229 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, message);
234 private synchronized void refreshHomeDevices(List<HomeDevice> homeDevices) {
235 for (HomeDevice hd : homeDevices) {
236 String key = hd.getApplianceIdentifier().getApplianceId();
237 if (!cachedHomeDevicesByApplianceId.containsKey(key)) {
238 logger.debug("A new appliance with ID '{}' has been added", hd.UID);
239 for (DiscoveryListener listener : discoveryListeners) {
240 listener.onApplianceAdded(hd);
242 ApplianceStatusListener listener = applianceStatusListeners
243 .get(hd.getApplianceIdentifier().getApplianceId());
244 if (listener != null) {
245 listener.onApplianceAdded(hd);
248 cachedHomeDevicesByApplianceId.put(key, hd);
249 cachedHomeDevicesByRemoteUid.put(hd.getRemoteUid(), hd);
252 Set<Entry<String, HomeDevice>> cachedEntries = cachedHomeDevicesByApplianceId.entrySet();
253 Iterator<Entry<String, HomeDevice>> iterator = cachedEntries.iterator();
255 while (iterator.hasNext()) {
256 Entry<String, HomeDevice> cachedEntry = iterator.next();
257 HomeDevice cachedHomeDevice = cachedEntry.getValue();
258 if (!homeDevices.stream().anyMatch(d -> d.UID.equals(cachedHomeDevice.UID))) {
259 logger.debug("The appliance with ID '{}' has been removed", cachedHomeDevice.UID);
260 for (DiscoveryListener listener : discoveryListeners) {
261 listener.onApplianceRemoved(cachedHomeDevice);
263 ApplianceStatusListener listener = applianceStatusListeners
264 .get(cachedHomeDevice.getApplianceIdentifier().getApplianceId());
265 if (listener != null) {
266 listener.onApplianceRemoved();
268 cachedHomeDevicesByRemoteUid.remove(cachedHomeDevice.getRemoteUid());
274 public List<HomeDevice> getHomeDevicesEmptyOnFailure() {
276 return getHomeDevices();
277 } catch (MieleRpcException e) {
278 Throwable cause = e.getCause();
280 logger.debug("An exception occurred while getting the home devices: '{}'", e.getMessage());
282 logger.debug("An exception occurred while getting the home devices: '{}' -> '{}", e.getMessage(),
285 return new ArrayList<>();
289 private List<HomeDevice> getHomeDevices() throws MieleRpcException {
290 List<HomeDevice> devices = new ArrayList<>();
292 if (!isInitialized()) {
296 String[] args = new String[1];
297 args[0] = "(type=SuperVision)";
298 JsonElement result = gatewayCommunication.invokeRPC("HDAccess/getHomeDevices", args);
300 for (JsonElement obj : result.getAsJsonArray()) {
301 HomeDevice hd = gson.fromJson(obj, HomeDevice.class);
309 private @Nullable FullyQualifiedApplianceIdentifier getApplianceIdentifierFromApplianceId(String applianceId) {
310 HomeDevice homeDevice = this.cachedHomeDevicesByApplianceId.get(applianceId);
311 if (homeDevice == null) {
315 return homeDevice.getApplianceIdentifier();
318 private Runnable eventListenerRunnable = () -> {
319 String interfaceIpAddress = (String) getConfig().get(INTERFACE);
320 if (!IP_PATTERN.matcher(interfaceIpAddress).matches()) {
321 logger.debug("Invalid IP address for the multicast interface: '{}'", interfaceIpAddress);
325 // Get the address that we are going to connect to.
326 InetSocketAddress address1 = null;
327 InetSocketAddress address2 = null;
329 address1 = new InetSocketAddress(InetAddress.getByName(JSON_RPC_MULTICAST_IP1), JSON_RPC_PORT);
330 address2 = new InetSocketAddress(InetAddress.getByName(JSON_RPC_MULTICAST_IP2), JSON_RPC_PORT);
331 } catch (UnknownHostException e) {
332 // This can only happen if the hardcoded literal IP addresses are invalid.
333 logger.debug("An exception occurred while setting up the multicast receiver: '{}'", e.getMessage());
337 while (!Thread.currentThread().isInterrupted()) {
338 MulticastSocket clientSocket = null;
340 clientSocket = new MulticastSocket(JSON_RPC_PORT);
341 clientSocket.setSoTimeout(MULTICAST_TIMEOUT_MILLIS);
343 NetworkInterface networkInterface = getMulticastInterface(interfaceIpAddress);
344 if (networkInterface == null) {
345 logger.warn("Unable to find network interface for address {}", interfaceIpAddress);
348 clientSocket.setNetworkInterface(networkInterface);
349 clientSocket.joinGroup(address1, null);
350 clientSocket.joinGroup(address2, null);
352 while (!Thread.currentThread().isInterrupted()) {
354 byte[] buf = new byte[256];
355 DatagramPacket packet = new DatagramPacket(buf, buf.length);
356 clientSocket.receive(packet);
358 String event = new String(packet.getData(), packet.getOffset(), packet.getLength(),
359 StandardCharsets.ISO_8859_1);
360 logger.debug("Received a multicast event '{}' from '{}:{}'", event, packet.getAddress(),
363 String[] parts = event.split("&");
364 String id = null, name = null, value = null;
365 for (String p : parts) {
366 String[] subparts = p.split("=");
367 switch (subparts[0]) {
383 if (id == null || name == null || value == null) {
387 // In XGW 3000 firmware 2.03 this was changed from UID (hdm:ZigBee:0123456789abcdef#210)
388 // to serial number (001234567890)
389 FullyQualifiedApplianceIdentifier applianceIdentifier;
390 if (id.startsWith("hdm:")) {
391 applianceIdentifier = new FullyQualifiedApplianceIdentifier(id);
393 HomeDevice device = cachedHomeDevicesByRemoteUid.get(id);
394 if (device == null) {
395 logger.debug("Multicast event not handled as id {} is unknown.", id);
398 applianceIdentifier = device.getApplianceIdentifier();
400 var deviceProperty = new DeviceProperty();
401 deviceProperty.Name = name;
402 deviceProperty.Value = value;
403 ApplianceStatusListener listener = applianceStatusListeners
404 .get(applianceIdentifier.getApplianceId());
405 if (listener != null) {
406 listener.onAppliancePropertyChanged(deviceProperty);
408 } catch (SocketTimeoutException e) {
410 Thread.sleep(MULTICAST_SLEEP_MILLIS);
411 } catch (InterruptedException ex) {
412 Thread.currentThread().interrupt();
413 logger.debug("Event listener has been interrupted.");
418 } catch (IOException e) {
419 logger.debug("An exception occurred while receiving multicast packets: '{}'", e.getMessage());
421 // restart the cycle with a clean slate
423 if (clientSocket != null) {
424 clientSocket.leaveGroup(address1, null);
425 clientSocket.leaveGroup(address2, null);
427 } catch (IOException e) {
428 logger.debug("An exception occurred while leaving multicast group: '{}'", e.getMessage());
430 if (clientSocket != null) {
431 clientSocket.close();
437 private @Nullable NetworkInterface getMulticastInterface(String interfaceIpAddress) throws SocketException {
438 Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces();
441 NetworkInterface networkInterface;
442 while (networkInterfaces.hasMoreElements()) {
443 networkInterface = networkInterfaces.nextElement();
444 if (networkInterface.isLoopback()) {
447 for (InterfaceAddress interfaceAddress : networkInterface.getInterfaceAddresses()) {
448 if (logger.isTraceEnabled()) {
449 logger.trace("Found interface address {} -> {}", interfaceAddress.toString(),
450 interfaceAddress.getAddress().toString());
452 if (interfaceAddress.getAddress().toString().endsWith("/" + interfaceIpAddress)) {
453 return networkInterface;
461 public JsonElement invokeOperation(String applianceId, String modelID, String methodName) throws MieleRpcException {
462 if (getThing().getStatus() != ThingStatus.ONLINE) {
463 throw new MieleRpcException("Bridge is offline, operations can not be invoked");
466 FullyQualifiedApplianceIdentifier applianceIdentifier = getApplianceIdentifierFromApplianceId(applianceId);
467 if (applianceIdentifier == null) {
468 throw new MieleRpcException("Appliance with ID" + applianceId
469 + " was not found in appliance list from gateway - operations can not be invoked");
472 return gatewayCommunication.invokeOperation(applianceIdentifier, modelID, methodName);
475 private synchronized void schedulePollingAndEventListener() {
476 logger.debug("Scheduling the Miele polling job");
477 ScheduledFuture<?> pollingJob = this.pollingJob;
478 if (pollingJob == null || pollingJob.isCancelled()) {
479 logger.trace("Scheduling the Miele polling job period is {}", POLLING_PERIOD_SECONDS);
480 pollingJob = scheduler.scheduleWithFixedDelay(pollingRunnable, 0, POLLING_PERIOD_SECONDS, TimeUnit.SECONDS);
481 this.pollingJob = pollingJob;
482 logger.trace("Scheduling the Miele polling job Job is done ?{}", pollingJob.isDone());
485 logger.debug("Scheduling the Miele event listener job");
486 Future<?> eventListenerJob = this.eventListenerJob;
487 if (eventListenerJob == null || eventListenerJob.isCancelled()) {
488 ExecutorService executor = Executors
489 .newSingleThreadExecutor(new NamedThreadFactory("binding-" + BINDING_ID));
490 this.executor = executor;
491 this.eventListenerJob = executor.submit(eventListenerRunnable);
495 public boolean registerApplianceStatusListener(String applianceId,
496 ApplianceStatusListener applianceStatusListener) {
497 ApplianceStatusListener existingListener = applianceStatusListeners.get(applianceId);
498 if (existingListener != null) {
499 if (!existingListener.equals(applianceStatusListener)) {
500 logger.warn("Unsupported configuration: appliance with ID '{}' referenced by multiple things",
503 logger.debug("Duplicate listener registration attempted for '{}'", applianceId);
507 applianceStatusListeners.put(applianceId, applianceStatusListener);
509 HomeDevice cachedHomeDevice = cachedHomeDevicesByApplianceId.get(applianceId);
510 if (cachedHomeDevice != null) {
511 applianceStatusListener.onApplianceAdded(cachedHomeDevice);
514 refreshHomeDevices(getHomeDevices());
515 } catch (MieleRpcException e) {
516 Throwable cause = e.getCause();
518 logger.debug("An exception occurred while getting the home devices: '{}'", e.getMessage());
520 logger.debug("An exception occurred while getting the home devices: '{}' -> '{}", e.getMessage(),
529 public boolean unregisterApplianceStatusListener(String applianceId,
530 ApplianceStatusListener applianceStatusListener) {
531 return applianceStatusListeners.remove(applianceId) != null;
534 public boolean registerDiscoveryListener(DiscoveryListener discoveryListener) {
535 if (!discoveryListeners.add(discoveryListener)) {
538 if (cachedHomeDevicesByApplianceId.isEmpty()) {
540 refreshHomeDevices(getHomeDevices());
541 } catch (MieleRpcException e) {
542 Throwable cause = e.getCause();
544 logger.debug("An exception occurred while getting the home devices: '{}'", e.getMessage());
546 logger.debug("An exception occurred while getting the home devices: '{}' -> '{}'", e.getMessage(),
551 for (Entry<String, HomeDevice> entry : cachedHomeDevicesByApplianceId.entrySet()) {
552 discoveryListener.onApplianceAdded(entry.getValue());
558 public boolean unregisterDiscoveryListener(DiscoveryListener discoveryListener) {
559 return discoveryListeners.remove(discoveryListener);
563 public void handleCommand(ChannelUID channelUID, Command command) {
564 // Nothing to do here - the XGW bridge does not handle commands, for now
565 if (command instanceof RefreshType) {
566 // Placeholder for future refinement
572 public void dispose() {
574 ScheduledFuture<?> pollingJob = this.pollingJob;
575 if (pollingJob != null) {
576 pollingJob.cancel(true);
577 this.pollingJob = null;
579 Future<?> eventListenerJob = this.eventListenerJob;
580 if (eventListenerJob != null) {
581 eventListenerJob.cancel(true);
582 this.eventListenerJob = null;
584 ExecutorService executor = this.executor;
585 if (executor != null) {
586 executor.shutdownNow();
587 this.executor = null;