2 * Copyright (c) 2010-2022 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.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;
30 import java.util.Map.Entry;
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;
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;
62 import com.google.gson.Gson;
63 import com.google.gson.JsonElement;
66 * The {@link MieleBridgeHandler} is responsible for handling commands, which are
67 * sent to one of the channels.
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)
75 public class MieleBridgeHandler extends BaseBridgeHandler {
77 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_XGW3000);
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])$");
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";
87 private final Logger logger = LoggerFactory.getLogger(MieleBridgeHandler.class);
89 private boolean lastBridgeConnectionState = false;
91 private final HttpClient httpClient;
92 private final Gson gson = new Gson();
93 private @NonNullByDefault({}) MieleGatewayCommunicationController gatewayCommunication;
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;
101 private Map<String, HomeDevice> cachedHomeDevicesByApplianceId = new ConcurrentHashMap<>();
102 private Map<String, HomeDevice> cachedHomeDevicesByRemoteUid = new ConcurrentHashMap<>();
104 public MieleBridgeHandler(Bridge bridge, HttpClient httpClient) {
106 this.httpClient = httpClient;
110 public void initialize() {
111 logger.debug("Initializing handler for bridge {}", getThing().getUID());
113 if (!validateConfig(getConfig())) {
118 gatewayCommunication = new MieleGatewayCommunicationController(httpClient, (String) getConfig().get(HOST));
119 } catch (URISyntaxException e) {
120 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
124 updateStatus(ThingStatus.UNKNOWN);
125 lastBridgeConnectionState = false;
126 schedulePollingAndEventListener();
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");
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");
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) + "\"]");
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)
151 String language = (String) config.get(LANGUAGE);
152 if (language != null && !language.isBlank()) {
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 + "\"]");
164 private Runnable pollingRunnable = new Runnable() {
167 String host = (String) getConfig().get(HOST);
169 List<HomeDevice> homeDevices = getHomeDevices();
171 if (!lastBridgeConnectionState) {
172 logger.debug("Connection to Miele Gateway {} established.", host);
173 lastBridgeConnectionState = true;
175 updateStatus(ThingStatus.ONLINE);
177 refreshHomeDevices(homeDevices);
179 for (Entry<String, ApplianceStatusListener> entry : applianceStatusListeners.entrySet()) {
180 String applianceId = entry.getKey();
181 ApplianceStatusListener listener = entry.getValue();
182 FullyQualifiedApplianceIdentifier applianceIdentifier = getApplianceIdentifierFromApplianceId(
184 if (applianceIdentifier == null) {
185 logger.debug("The appliance with ID '{}' was not found in appliance list from bridge.",
187 listener.onApplianceRemoved();
191 Object[] args = new Object[2];
192 args[0] = applianceIdentifier.getUid();
194 JsonElement result = gatewayCommunication.invokeRPC("HDAccess/getDeviceClassObjects", args);
196 for (JsonElement obj : result.getAsJsonArray()) {
198 DeviceClassObject dco = gson.fromJson(obj, DeviceClassObject.class);
200 // Skip com.prosyst.mbs.services.zigbee.hdm.deviceclasses.ReportingControl
201 if (dco == null || !dco.DeviceClass.startsWith(MIELE_CLASS)) {
205 listener.onApplianceStateChanged(dco);
206 } catch (Exception e) {
207 logger.debug("An exception occurred while querying an appliance : '{}'", e.getMessage());
211 } catch (MieleRpcException e) {
212 Throwable cause = e.getCause();
214 logger.debug("An exception occurred while polling an appliance: '{}'", e.getMessage());
216 logger.debug("An exception occurred while polling an appliance: '{}' -> '{}'", e.getMessage(),
219 if (lastBridgeConnectionState) {
220 logger.debug("Connection to Miele Gateway {} lost.", host);
221 lastBridgeConnectionState = false;
223 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
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);
236 ApplianceStatusListener listener = applianceStatusListeners
237 .get(hd.getApplianceIdentifier().getApplianceId());
238 if (listener != null) {
239 listener.onApplianceAdded(hd);
242 cachedHomeDevicesByApplianceId.put(key, hd);
243 cachedHomeDevicesByRemoteUid.put(hd.getRemoteUid(), hd);
246 Set<Entry<String, HomeDevice>> cachedEntries = cachedHomeDevicesByApplianceId.entrySet();
247 Iterator<Entry<String, HomeDevice>> iterator = cachedEntries.iterator();
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);
257 ApplianceStatusListener listener = applianceStatusListeners
258 .get(cachedHomeDevice.getApplianceIdentifier().getApplianceId());
259 if (listener != null) {
260 listener.onApplianceRemoved();
262 cachedHomeDevicesByRemoteUid.remove(cachedHomeDevice.getRemoteUid());
268 public List<HomeDevice> getHomeDevicesEmptyOnFailure() {
270 return getHomeDevices();
271 } catch (MieleRpcException e) {
272 Throwable cause = e.getCause();
274 logger.debug("An exception occurred while getting the home devices: '{}'", e.getMessage());
276 logger.debug("An exception occurred while getting the home devices: '{}' -> '{}", e.getMessage(),
279 return new ArrayList<>();
283 private List<HomeDevice> getHomeDevices() throws MieleRpcException {
284 List<HomeDevice> devices = new ArrayList<>();
286 if (!isInitialized()) {
290 String[] args = new String[1];
291 args[0] = "(type=SuperVision)";
292 JsonElement result = gatewayCommunication.invokeRPC("HDAccess/getHomeDevices", args);
294 for (JsonElement obj : result.getAsJsonArray()) {
295 HomeDevice hd = gson.fromJson(obj, HomeDevice.class);
303 private @Nullable FullyQualifiedApplianceIdentifier getApplianceIdentifierFromApplianceId(String applianceId) {
304 HomeDevice homeDevice = this.cachedHomeDevicesByApplianceId.get(applianceId);
305 if (homeDevice == null) {
309 return homeDevice.getApplianceIdentifier();
312 private Runnable eventListenerRunnable = () -> {
313 if (IP_PATTERN.matcher((String) getConfig().get(INTERFACE)).matches()) {
315 // Get the address that we are going to connect to.
316 InetAddress address1 = null;
317 InetAddress address2 = null;
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());
325 byte[] buf = new byte[256];
326 MulticastSocket clientSocket = null;
330 clientSocket = new MulticastSocket(JSON_RPC_PORT);
331 clientSocket.setSoTimeout(100);
333 clientSocket.setInterface(InetAddress.getByName((String) getConfig().get(INTERFACE)));
334 clientSocket.joinGroup(address1);
335 clientSocket.joinGroup(address2);
340 DatagramPacket packet = new DatagramPacket(buf, buf.length);
341 clientSocket.receive(packet);
343 String event = new String(packet.getData());
344 logger.debug("Received a multicast event '{}' from '{}:{}'", event, packet.getAddress(),
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]) {
357 value = subparts[1].strip().trim();
367 if (id == null || name == null || value == null) {
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);
377 HomeDevice device = cachedHomeDevicesByRemoteUid.get(id);
378 if (device == null) {
379 logger.debug("Multicast event not handled as id {} is unknown.", id);
382 applianceIdentifier = device.getApplianceIdentifier();
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);
392 } catch (SocketTimeoutException e) {
395 } catch (InterruptedException ex) {
396 logger.debug("Event listener has been interrupted.");
401 } catch (Exception ex) {
402 logger.debug("An exception occurred while receiving multicast packets: '{}'", ex.getMessage());
405 // restart the cycle with a clean slate
407 if (clientSocket != null) {
408 clientSocket.leaveGroup(address1);
409 clientSocket.leaveGroup(address2);
411 } catch (IOException e) {
412 logger.debug("An exception occurred while leaving multicast group: '{}'", e.getMessage());
414 if (clientSocket != null) {
415 clientSocket.close();
420 logger.debug("Invalid IP address for the multicast interface: '{}'", getConfig().get(INTERFACE));
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");
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");
435 return gatewayCommunication.invokeOperation(applianceIdentifier, modelID, methodName);
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());
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);
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",
466 logger.debug("Duplicate listener registration attempted for '{}'", applianceId);
470 applianceStatusListeners.put(applianceId, applianceStatusListener);
472 HomeDevice cachedHomeDevice = cachedHomeDevicesByApplianceId.get(applianceId);
473 if (cachedHomeDevice != null) {
474 applianceStatusListener.onApplianceAdded(cachedHomeDevice);
477 refreshHomeDevices(getHomeDevices());
478 } catch (MieleRpcException e) {
479 Throwable cause = e.getCause();
481 logger.debug("An exception occurred while getting the home devices: '{}'", e.getMessage());
483 logger.debug("An exception occurred while getting the home devices: '{}' -> '{}", e.getMessage(),
492 public boolean unregisterApplianceStatusListener(String applianceId,
493 ApplianceStatusListener applianceStatusListener) {
494 return applianceStatusListeners.remove(applianceId) != null;
497 public boolean registerDiscoveryListener(DiscoveryListener discoveryListener) {
498 if (!discoveryListeners.add(discoveryListener)) {
501 if (cachedHomeDevicesByApplianceId.isEmpty()) {
503 refreshHomeDevices(getHomeDevices());
504 } catch (MieleRpcException e) {
505 Throwable cause = e.getCause();
507 logger.debug("An exception occurred while getting the home devices: '{}'", e.getMessage());
509 logger.debug("An exception occurred while getting the home devices: '{}' -> '{}", e.getMessage(),
514 for (Entry<String, HomeDevice> entry : cachedHomeDevicesByApplianceId.entrySet()) {
515 discoveryListener.onApplianceAdded(entry.getValue());
521 public boolean unregisterDiscoveryListener(DiscoveryListener discoveryListener) {
522 return discoveryListeners.remove(discoveryListener);
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
535 public void dispose() {
537 ScheduledFuture<?> pollingJob = this.pollingJob;
538 if (pollingJob != null) {
539 pollingJob.cancel(true);
540 this.pollingJob = null;
542 Future<?> eventListenerJob = this.eventListenerJob;
543 if (eventListenerJob != null) {
544 eventListenerJob.cancel(true);
545 this.eventListenerJob = null;
547 ExecutorService executor = this.executor;
548 if (executor != null) {
549 executor.shutdownNow();
550 this.executor = null;