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.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;
30 import java.net.UnknownHostException;
31 import java.util.ArrayList;
32 import java.util.Collections;
33 import java.util.IllformedLocaleException;
34 import java.util.Iterator;
35 import java.util.List;
36 import java.util.Locale;
38 import java.util.Map.Entry;
39 import java.util.Random;
41 import java.util.concurrent.ConcurrentHashMap;
42 import java.util.concurrent.CopyOnWriteArrayList;
43 import java.util.concurrent.ExecutorService;
44 import java.util.concurrent.Executors;
45 import java.util.concurrent.Future;
46 import java.util.concurrent.ScheduledFuture;
47 import java.util.concurrent.TimeUnit;
48 import java.util.regex.Pattern;
49 import java.util.zip.GZIPInputStream;
51 import org.eclipse.jdt.annotation.NonNullByDefault;
52 import org.eclipse.jdt.annotation.Nullable;
53 import org.openhab.binding.miele.internal.FullyQualifiedApplianceIdentifier;
54 import org.openhab.binding.miele.internal.api.dto.DeviceClassObject;
55 import org.openhab.binding.miele.internal.api.dto.DeviceProperty;
56 import org.openhab.binding.miele.internal.api.dto.HomeDevice;
57 import org.openhab.binding.miele.internal.exceptions.MieleRpcException;
58 import org.openhab.core.common.NamedThreadFactory;
59 import org.openhab.core.config.core.Configuration;
60 import org.openhab.core.thing.Bridge;
61 import org.openhab.core.thing.ChannelUID;
62 import org.openhab.core.thing.Thing;
63 import org.openhab.core.thing.ThingStatus;
64 import org.openhab.core.thing.ThingStatusDetail;
65 import org.openhab.core.thing.ThingTypeUID;
66 import org.openhab.core.thing.binding.BaseBridgeHandler;
67 import org.openhab.core.types.Command;
68 import org.openhab.core.types.RefreshType;
69 import org.slf4j.Logger;
70 import org.slf4j.LoggerFactory;
72 import com.google.gson.Gson;
73 import com.google.gson.JsonArray;
74 import com.google.gson.JsonElement;
75 import com.google.gson.JsonObject;
76 import com.google.gson.JsonParseException;
77 import com.google.gson.JsonParser;
80 * The {@link MieleBridgeHandler} is responsible for handling commands, which are
81 * sent to one of the channels.
83 * @author Karel Goderis - Initial contribution
84 * @author Kai Kreuzer - Fixed lifecycle issues
85 * @author Martin Lepsy - Added protocol information to support WiFi devices & some refactoring for HomeDevice
86 * @author Jacob Laursen - Fixed multicast and protocol support (ZigBee/LAN)
89 public class MieleBridgeHandler extends BaseBridgeHandler {
91 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_XGW3000);
93 private static final Pattern IP_PATTERN = Pattern
94 .compile("^(([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.){3}([01]?\\d\\d?|2[0-4]\\d|25[0-5])$");
96 protected static final int POLLING_PERIOD = 15; // in seconds
97 protected static final int JSON_RPC_PORT = 2810;
98 protected static final String JSON_RPC_MULTICAST_IP1 = "239.255.68.139";
99 protected static final String JSON_RPC_MULTICAST_IP2 = "224.255.68.139";
100 private boolean lastBridgeConnectionState = false;
101 private boolean currentBridgeConnectionState = false;
103 protected Random rand = new Random();
104 protected Gson gson = new Gson();
105 private final Logger logger = LoggerFactory.getLogger(MieleBridgeHandler.class);
107 protected List<ApplianceStatusListener> applianceStatusListeners = new CopyOnWriteArrayList<>();
108 protected @Nullable ScheduledFuture<?> pollingJob;
109 protected @Nullable ExecutorService executor;
110 protected @Nullable Future<?> eventListenerJob;
112 protected Map<String, HomeDevice> cachedHomeDevicesByApplianceId = new ConcurrentHashMap<String, HomeDevice>();
113 protected Map<String, HomeDevice> cachedHomeDevicesByRemoteUid = new ConcurrentHashMap<String, HomeDevice>();
115 protected @Nullable URL url;
117 public MieleBridgeHandler(Bridge bridge) {
122 public void initialize() {
123 logger.debug("Initializing the Miele bridge handler.");
125 if (!validateConfig(getConfig())) {
130 url = new URL("http://" + (String) getConfig().get(HOST) + "/remote/json-rpc");
131 } catch (MalformedURLException e) {
132 logger.debug("An exception occurred while defining an URL :'{}'", e.getMessage());
133 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
138 lastBridgeConnectionState = false;
139 updateStatus(ThingStatus.UNKNOWN);
142 private boolean validateConfig(Configuration config) {
143 if (config.get(HOST) == null || ((String) config.get(HOST)).isBlank()) {
144 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
145 "@text/offline.configuration-error.ip-address-not-set");
148 if (config.get(INTERFACE) == null || ((String) config.get(INTERFACE)).isBlank()) {
149 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
150 "@text/offline.configuration-error.ip-multicast-interface-not-set");
153 if (!IP_PATTERN.matcher((String) config.get(HOST)).matches()) {
154 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
155 "@text/offline.configuration-error.invalid-ip-gateway [\"" + config.get(HOST) + "\"]");
158 if (!IP_PATTERN.matcher((String) config.get(INTERFACE)).matches()) {
159 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
160 "@text/offline.configuration-error.invalid-ip-multicast-interface [\"" + config.get(INTERFACE)
164 String language = (String) config.get(LANGUAGE);
165 if (language != null && !language.isBlank()) {
167 new Locale.Builder().setLanguageTag(language).build();
168 } catch (IllformedLocaleException e) {
169 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
170 "@text/offline.configuration-error.invalid-language [\"" + language + "\"]");
177 private Runnable pollingRunnable = new Runnable() {
180 if (!IP_PATTERN.matcher((String) getConfig().get(HOST)).matches()) {
181 logger.debug("Invalid IP address for the Miele@Home gateway : '{}'", getConfig().get(HOST));
186 if (isReachable((String) getConfig().get(HOST))) {
187 currentBridgeConnectionState = true;
189 currentBridgeConnectionState = false;
190 lastBridgeConnectionState = false;
194 if (!lastBridgeConnectionState && currentBridgeConnectionState) {
195 logger.debug("Connection to Miele Gateway {} established.", getConfig().get(HOST));
196 lastBridgeConnectionState = true;
197 onConnectionResumed();
200 if (!currentBridgeConnectionState || getThing().getStatus() != ThingStatus.ONLINE) {
204 List<HomeDevice> homeDevices = getHomeDevices();
205 for (HomeDevice hd : homeDevices) {
206 String key = hd.getApplianceIdentifier().getApplianceId();
207 if (!cachedHomeDevicesByApplianceId.containsKey(key)) {
208 logger.debug("A new appliance with ID '{}' has been added", hd.UID);
209 for (ApplianceStatusListener listener : applianceStatusListeners) {
210 listener.onApplianceAdded(hd);
213 cachedHomeDevicesByApplianceId.put(key, hd);
214 cachedHomeDevicesByRemoteUid.put(hd.getRemoteUid(), hd);
217 Set<Entry<String, HomeDevice>> cachedEntries = cachedHomeDevicesByApplianceId.entrySet();
218 Iterator<Entry<String, HomeDevice>> iterator = cachedEntries.iterator();
220 while (iterator.hasNext()) {
221 Entry<String, HomeDevice> cachedEntry = iterator.next();
222 HomeDevice cachedHomeDevice = cachedEntry.getValue();
223 if (!homeDevices.stream().anyMatch(d -> d.UID.equals(cachedHomeDevice.UID))) {
224 logger.debug("The appliance with ID '{}' has been removed", cachedHomeDevice.UID);
225 for (ApplianceStatusListener listener : applianceStatusListeners) {
226 listener.onApplianceRemoved(cachedHomeDevice);
228 cachedHomeDevicesByRemoteUid.remove(cachedHomeDevice.getRemoteUid());
233 for (Thing appliance : getThing().getThings()) {
234 if (appliance.getStatus() == ThingStatus.ONLINE) {
235 String applianceId = (String) appliance.getConfiguration().getProperties().get(APPLIANCE_ID);
236 FullyQualifiedApplianceIdentifier applianceIdentifier = null;
237 if (applianceId != null) {
238 applianceIdentifier = getApplianceIdentifierFromApplianceId(applianceId);
241 if (applianceIdentifier == null) {
242 logger.warn("The appliance with ID '{}' was not found in appliance list from bridge.",
247 Object[] args = new Object[2];
248 args[0] = applianceIdentifier.getUid();
250 JsonElement result = invokeRPC("HDAccess/getDeviceClassObjects", args);
252 for (JsonElement obj : result.getAsJsonArray()) {
254 DeviceClassObject dco = gson.fromJson(obj, DeviceClassObject.class);
256 // Skip com.prosyst.mbs.services.zigbee.hdm.deviceclasses.ReportingControl
257 if (dco == null || !dco.DeviceClass.startsWith(MIELE_CLASS)) {
261 for (ApplianceStatusListener listener : applianceStatusListeners) {
262 listener.onApplianceStateChanged(applianceIdentifier, dco);
264 } catch (Exception e) {
265 logger.debug("An exception occurred while querying an appliance : '{}'",
271 } catch (MieleRpcException e) {
272 Throwable cause = e.getCause();
274 logger.debug("An exception occurred while polling an appliance: '{}'", e.getMessage());
276 logger.debug("An exception occurred while polling an appliance: '{}' -> '{}'", e.getMessage(),
282 private boolean isReachable(String ipAddress) {
284 // note that InetAddress.isReachable is unreliable, see
285 // http://stackoverflow.com/questions/9922543/why-does-inetaddress-isreachable-return-false-when-i-can-ping-the-ip-address
286 // That's why we do an HTTP access instead
288 // If there is no connection, this line will fail
289 invokeRPC("system.listMethods", new Object[0]);
290 } catch (MieleRpcException e) {
291 logger.debug("{} is not reachable", ipAddress);
295 logger.debug("{} is reachable", ipAddress);
300 public List<HomeDevice> getHomeDevices() {
301 List<HomeDevice> devices = new ArrayList<>();
303 if (getThing().getStatus() == ThingStatus.ONLINE) {
305 String[] args = new String[1];
306 args[0] = "(type=SuperVision)";
307 JsonElement result = invokeRPC("HDAccess/getHomeDevices", args);
309 for (JsonElement obj : result.getAsJsonArray()) {
310 HomeDevice hd = gson.fromJson(obj, HomeDevice.class);
315 } catch (MieleRpcException e) {
316 Throwable cause = e.getCause();
318 logger.debug("An exception occurred while getting the home devices: '{}'", e.getMessage());
320 logger.debug("An exception occurred while getting the home devices: '{}' -> '{}", e.getMessage(),
328 private @Nullable FullyQualifiedApplianceIdentifier getApplianceIdentifierFromApplianceId(String applianceId) {
329 HomeDevice homeDevice = this.cachedHomeDevicesByApplianceId.get(applianceId);
330 if (homeDevice == null) {
334 return homeDevice.getApplianceIdentifier();
337 private Runnable eventListenerRunnable = () -> {
338 if (IP_PATTERN.matcher((String) getConfig().get(INTERFACE)).matches()) {
340 // Get the address that we are going to connect to.
341 InetAddress address1 = null;
342 InetAddress address2 = null;
344 address1 = InetAddress.getByName(JSON_RPC_MULTICAST_IP1);
345 address2 = InetAddress.getByName(JSON_RPC_MULTICAST_IP2);
346 } catch (UnknownHostException e) {
347 logger.debug("An exception occurred while setting up the multicast receiver : '{}'",
351 byte[] buf = new byte[256];
352 MulticastSocket clientSocket = null;
356 clientSocket = new MulticastSocket(JSON_RPC_PORT);
357 clientSocket.setSoTimeout(100);
359 clientSocket.setInterface(InetAddress.getByName((String) getConfig().get(INTERFACE)));
360 clientSocket.joinGroup(address1);
361 clientSocket.joinGroup(address2);
366 DatagramPacket packet = new DatagramPacket(buf, buf.length);
367 clientSocket.receive(packet);
369 String event = new String(packet.getData());
370 logger.debug("Received a multicast event '{}' from '{}:{}'", event, packet.getAddress(),
373 String[] parts = event.split("&");
374 String id = null, name = null, value = null;
375 for (String p : parts) {
376 String[] subparts = p.split("=");
377 switch (subparts[0]) {
383 value = subparts[1].strip().trim();
393 if (id == null || name == null || value == null) {
397 // In XGW 3000 firmware 2.03 this was changed from UID (hdm:ZigBee:0123456789abcdef#210)
398 // to serial number (001234567890)
399 FullyQualifiedApplianceIdentifier applianceIdentifier;
400 if (id.startsWith("hdm:")) {
401 applianceIdentifier = new FullyQualifiedApplianceIdentifier(id);
403 HomeDevice device = cachedHomeDevicesByRemoteUid.get(id);
404 if (device == null) {
405 logger.debug("Multicast event not handled as id {} is unknown.", id);
408 applianceIdentifier = device.getApplianceIdentifier();
410 var deviceProperty = new DeviceProperty();
411 deviceProperty.Name = name;
412 deviceProperty.Value = value;
413 for (ApplianceStatusListener listener : applianceStatusListeners) {
414 listener.onAppliancePropertyChanged(applianceIdentifier, deviceProperty);
416 } catch (SocketTimeoutException e) {
419 } catch (InterruptedException ex) {
420 logger.debug("Eventlistener has been interrupted.");
425 } catch (Exception ex) {
426 logger.debug("An exception occurred while receiving multicast packets : '{}'", ex.getMessage());
429 // restart the cycle with a clean slate
431 if (clientSocket != null) {
432 clientSocket.leaveGroup(address1);
433 clientSocket.leaveGroup(address2);
435 } catch (IOException e) {
436 logger.debug("An exception occurred while leaving multicast group : '{}'", e.getMessage());
438 if (clientSocket != null) {
439 clientSocket.close();
444 logger.debug("Invalid IP address for the multicast interface : '{}'", getConfig().get(INTERFACE));
448 public JsonElement invokeOperation(String applianceId, String modelID, String methodName) throws MieleRpcException {
449 if (getThing().getStatus() != ThingStatus.ONLINE) {
450 throw new MieleRpcException("Bridge is offline, operations can not be invoked");
453 FullyQualifiedApplianceIdentifier applianceIdentifier = getApplianceIdentifierFromApplianceId(applianceId);
454 if (applianceIdentifier == null) {
455 throw new MieleRpcException("Appliance with ID" + applianceId
456 + " was not found in appliance list from gateway - operations can not be invoked");
459 Object[] args = new Object[4];
460 args[0] = applianceIdentifier.getUid();
461 args[1] = MIELE_CLASS + modelID;
462 args[2] = methodName;
465 return invokeRPC("HDAccess/invokeDCOOperation", args);
468 protected JsonElement invokeRPC(String methodName, Object[] args) throws MieleRpcException {
469 JsonElement result = null;
472 throw new MieleRpcException("URL is not set");
475 JsonObject req = new JsonObject();
476 int id = rand.nextInt(Integer.MAX_VALUE);
477 req.addProperty("jsonrpc", "2.0");
478 req.addProperty("id", id);
479 req.addProperty("method", methodName);
481 JsonArray params = new JsonArray();
482 for (Object o : args) {
483 params.add(gson.toJsonTree(o));
485 req.add("params", params);
487 String requestData = req.toString();
488 String responseData = null;
490 responseData = post(url, Collections.emptyMap(), requestData);
491 } catch (IOException e) {
492 throw new MieleRpcException("Exception occurred while posting data", e);
495 logger.trace("The request '{}' yields '{}'", requestData, responseData);
496 JsonObject parsedResponse = null;
498 parsedResponse = (JsonObject) JsonParser.parseReader(new StringReader(responseData));
499 } catch (JsonParseException e) {
500 throw new MieleRpcException("Error parsing JSON response", e);
503 JsonElement error = parsedResponse.get("error");
504 if (error != null && !error.isJsonNull()) {
505 if (error.isJsonPrimitive()) {
506 throw new MieleRpcException("Remote exception occurred: '" + error.getAsString() + "'");
507 } else if (error.isJsonObject()) {
508 JsonObject o = error.getAsJsonObject();
509 Integer code = (o.has("code") ? o.get("code").getAsInt() : null);
510 String message = (o.has("message") ? o.get("message").getAsString() : null);
511 String data = (o.has("data")
512 ? (o.get("data") instanceof JsonObject ? o.get("data").toString() : o.get("data").getAsString())
514 throw new MieleRpcException(
515 "Remote exception occurred: '" + code + "':'" + message + "':'" + data + "'");
517 throw new MieleRpcException("Unknown remote exception occurred: '" + error.toString() + "'");
521 result = parsedResponse.get("result");
522 if (result == null) {
523 throw new MieleRpcException("Result is missing in response");
529 protected String post(URL url, Map<String, String> headers, String data) throws IOException {
530 HttpURLConnection connection = (HttpURLConnection) url.openConnection();
532 for (Map.Entry<String, String> entry : headers.entrySet()) {
533 connection.addRequestProperty(entry.getKey(), entry.getValue());
536 connection.addRequestProperty("Accept-Encoding", "gzip");
538 connection.setRequestMethod("POST");
539 connection.setDoOutput(true);
540 connection.connect();
542 OutputStream out = null;
545 out = connection.getOutputStream();
547 out.write(data.getBytes());
550 int statusCode = connection.getResponseCode();
551 if (statusCode != HttpURLConnection.HTTP_OK) {
552 logger.debug("An unexpected status code was returned: '{}'", statusCode);
560 String responseEncoding = connection.getHeaderField("Content-Encoding");
561 responseEncoding = (responseEncoding == null ? "" : responseEncoding.trim());
563 ByteArrayOutputStream bos = new ByteArrayOutputStream();
565 InputStream in = connection.getInputStream();
567 in = connection.getInputStream();
568 if ("gzip".equalsIgnoreCase(responseEncoding)) {
569 in = new GZIPInputStream(in);
571 in = new BufferedInputStream(in);
573 byte[] buff = new byte[1024];
575 while ((n = in.read(buff)) > 0) {
576 bos.write(buff, 0, n);
586 return bos.toString();
589 private synchronized void onUpdate() {
590 logger.debug("Scheduling the Miele polling job");
591 ScheduledFuture<?> pollingJob = this.pollingJob;
592 if (pollingJob == null || pollingJob.isCancelled()) {
593 logger.trace("Scheduling the Miele polling job period is {}", POLLING_PERIOD);
594 pollingJob = scheduler.scheduleWithFixedDelay(pollingRunnable, 0, POLLING_PERIOD, TimeUnit.SECONDS);
595 this.pollingJob = pollingJob;
596 logger.trace("Scheduling the Miele polling job Job is done ?{}", pollingJob.isDone());
598 logger.debug("Scheduling the Miele event listener job");
600 Future<?> eventListenerJob = this.eventListenerJob;
601 if (eventListenerJob == null || eventListenerJob.isCancelled()) {
602 ExecutorService executor = Executors
603 .newSingleThreadExecutor(new NamedThreadFactory("binding-" + BINDING_ID));
604 this.executor = executor;
605 this.eventListenerJob = executor.submit(eventListenerRunnable);
610 * This method is called whenever the connection to the given {@link MieleBridge} is lost.
613 public void onConnectionLost() {
614 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
618 * This method is called whenever the connection to the given {@link MieleBridge} is resumed.
620 * @param bridge the Miele bridge the connection is resumed to
622 public void onConnectionResumed() {
623 updateStatus(ThingStatus.ONLINE);
624 for (Thing thing : getThing().getThings()) {
625 MieleApplianceHandler<?> handler = (MieleApplianceHandler<?>) thing.getHandler();
626 if (handler != null) {
627 handler.onBridgeConnectionResumed();
632 public boolean registerApplianceStatusListener(ApplianceStatusListener applianceStatusListener) {
633 boolean result = applianceStatusListeners.add(applianceStatusListener);
634 if (result && isInitialized()) {
637 for (HomeDevice hd : getHomeDevices()) {
638 applianceStatusListener.onApplianceAdded(hd);
644 public boolean unregisterApplianceStatusListener(ApplianceStatusListener applianceStatusListener) {
645 boolean result = applianceStatusListeners.remove(applianceStatusListener);
646 if (result && isInitialized()) {
653 public void handleCommand(ChannelUID channelUID, Command command) {
654 // Nothing to do here - the XGW bridge does not handle commands, for now
655 if (command instanceof RefreshType) {
656 // Placeholder for future refinement
662 public void dispose() {
664 ScheduledFuture<?> pollingJob = this.pollingJob;
665 if (pollingJob != null) {
666 pollingJob.cancel(true);
667 this.pollingJob = null;
669 Future<?> eventListenerJob = this.eventListenerJob;
670 if (eventListenerJob != null) {
671 eventListenerJob.cancel(true);
672 this.eventListenerJob = null;
674 ExecutorService executor = this.executor;
675 if (executor != null) {
676 executor.shutdownNow();
677 this.executor = null;