]> git.basschouten.com Git - openhab-addons.git/blob
04261495da3f21b3481d411507f2ac7bd617eae7
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.elroconnects.internal.handler;
14
15 import static org.openhab.binding.elroconnects.internal.ElroConnectsBindingConstants.*;
16
17 import java.io.IOException;
18 import java.net.DatagramPacket;
19 import java.net.DatagramSocket;
20 import java.net.InetAddress;
21 import java.net.SocketException;
22 import java.net.UnknownHostException;
23 import java.nio.charset.StandardCharsets;
24 import java.util.ArrayList;
25 import java.util.Collection;
26 import java.util.Collections;
27 import java.util.List;
28 import java.util.Map;
29 import java.util.concurrent.CompletableFuture;
30 import java.util.concurrent.ConcurrentHashMap;
31 import java.util.concurrent.ExecutionException;
32 import java.util.concurrent.ScheduledFuture;
33 import java.util.concurrent.TimeUnit;
34 import java.util.concurrent.TimeoutException;
35 import java.util.regex.Matcher;
36 import java.util.regex.Pattern;
37 import java.util.stream.Collectors;
38
39 import org.eclipse.jdt.annotation.NonNullByDefault;
40 import org.eclipse.jdt.annotation.Nullable;
41 import org.openhab.binding.elroconnects.internal.ElroConnectsBindingConstants.ElroDeviceType;
42 import org.openhab.binding.elroconnects.internal.ElroConnectsDynamicStateDescriptionProvider;
43 import org.openhab.binding.elroconnects.internal.ElroConnectsMessage;
44 import org.openhab.binding.elroconnects.internal.devices.ElroConnectsDevice;
45 import org.openhab.binding.elroconnects.internal.devices.ElroConnectsDeviceCxsmAlarm;
46 import org.openhab.binding.elroconnects.internal.devices.ElroConnectsDeviceEntrySensor;
47 import org.openhab.binding.elroconnects.internal.devices.ElroConnectsDeviceGenericAlarm;
48 import org.openhab.binding.elroconnects.internal.devices.ElroConnectsDeviceMotionSensor;
49 import org.openhab.binding.elroconnects.internal.devices.ElroConnectsDevicePowerSocket;
50 import org.openhab.binding.elroconnects.internal.devices.ElroConnectsDeviceTemperatureSensor;
51 import org.openhab.binding.elroconnects.internal.discovery.ElroConnectsDiscoveryService;
52 import org.openhab.binding.elroconnects.internal.util.ElroConnectsUtil;
53 import org.openhab.core.common.NamedThreadFactory;
54 import org.openhab.core.config.core.Configuration;
55 import org.openhab.core.library.types.StringType;
56 import org.openhab.core.net.NetworkAddressService;
57 import org.openhab.core.thing.Bridge;
58 import org.openhab.core.thing.Channel;
59 import org.openhab.core.thing.ChannelUID;
60 import org.openhab.core.thing.ThingStatus;
61 import org.openhab.core.thing.ThingStatusDetail;
62 import org.openhab.core.thing.binding.BaseBridgeHandler;
63 import org.openhab.core.thing.binding.ThingHandler;
64 import org.openhab.core.thing.binding.ThingHandlerService;
65 import org.openhab.core.types.Command;
66 import org.openhab.core.types.RefreshType;
67 import org.openhab.core.types.StateDescription;
68 import org.openhab.core.types.StateDescriptionFragmentBuilder;
69 import org.openhab.core.types.StateOption;
70 import org.slf4j.Logger;
71 import org.slf4j.LoggerFactory;
72
73 import com.google.gson.Gson;
74 import com.google.gson.JsonSyntaxException;
75
76 /**
77  * The {@link ElroConnectsBridgeHandler} is the bridge handler responsible to for handling all communication with the
78  * ELRO Connects K1 Hub. All individual device communication passes through the hub.
79  *
80  * @author Mark Herwege - Initial contribution
81  */
82 @NonNullByDefault
83 public class ElroConnectsBridgeHandler extends BaseBridgeHandler {
84
85     private final Logger logger = LoggerFactory.getLogger(ElroConnectsBridgeHandler.class);
86
87     private static final int PORT = 1025; // UDP port for UDP socket communication with K1 hub
88     private static final int RESPONSE_TIMEOUT_MS = 5000; // max time to wait for receiving all responses on a request
89
90     // Default scene names are not received from K1 hub, so kept here
91     private static final Map<Integer, String> DEFAULT_SCENES = Map.ofEntries(Map.entry(0, "Home"), Map.entry(1, "Away"),
92             Map.entry(2, "Sleep"));
93     private static final int MAX_DEFAULT_SCENE = 2;
94
95     // Command filter when syncing devices and scenes, other values would filter what gets received
96     private static final String SYNC_COMMAND = "0002";
97
98     // Regex for valid connectorId
99     private static final Pattern CONNECTOR_ID_PATTERN = Pattern.compile("^ST_([0-9a-f]){12}$");
100
101     // Message string for acknowledging receipt of data
102     private static final String ACK_STRING = "{\"answer\": \"APP_answer_OK\"}";
103     private static final byte[] ACK = ACK_STRING.getBytes(StandardCharsets.UTF_8);
104
105     // Connector expects to receive messages with an increasing id for each message
106     // Max msgId is 65536, therefore use short and convert to unsigned Integer when using it
107     private short msgId;
108
109     private NetworkAddressService networkAddressService;
110
111     // Used when restarting connection, delay restart for 1s to avoid high network traffic
112     private volatile boolean restart;
113     static final int RESTART_DELAY_MS = 1000;
114
115     private volatile String connectorId = "";
116     // Used for getting IP address and keep connection alive messages
117     private static final String QUERY_BASE_STRING = "IOT_KEY?";
118     private volatile String queryString = QUERY_BASE_STRING + connectorId;
119     // Regex to retrieve ctrlKey from response on IP address message
120     private static final Pattern CTRL_KEY_PATTERN = Pattern.compile("KEY:([0-9a-f]*)");
121
122     private int refreshInterval = 60;
123     private volatile @Nullable InetAddress addr;
124     private volatile String ctrlKey = "";
125
126     private boolean legacyFirmware = false;
127
128     private volatile @Nullable DatagramSocket socket;
129     private volatile @Nullable DatagramPacket ackPacket;
130
131     private volatile @Nullable ScheduledFuture<?> syncFuture;
132     private volatile @Nullable CompletableFuture<Boolean> awaitResponse;
133
134     private ElroConnectsDynamicStateDescriptionProvider stateDescriptionProvider;
135
136     private final Map<Integer, String> scenes = new ConcurrentHashMap<>();
137     private final Map<Integer, ElroConnectsDevice> devices = new ConcurrentHashMap<>();
138     private final Map<Integer, ElroConnectsDeviceHandler> deviceHandlers = new ConcurrentHashMap<>();
139
140     private int currentScene;
141
142     // We only keep 2 gson adapters used to serialize and deserialize all messages sent and received
143     private final Gson gsonOut = new Gson();
144     private Gson gsonIn = new Gson();
145
146     private @Nullable ElroConnectsDiscoveryService discoveryService = null;
147
148     public ElroConnectsBridgeHandler(Bridge bridge, NetworkAddressService networkAddressService,
149             ElroConnectsDynamicStateDescriptionProvider stateDescriptionProvider) {
150         super(bridge);
151         this.networkAddressService = networkAddressService;
152         this.stateDescriptionProvider = stateDescriptionProvider;
153
154         resetScenes();
155     }
156
157     @Override
158     public void initialize() {
159         ElroConnectsBridgeConfiguration config = getConfigAs(ElroConnectsBridgeConfiguration.class);
160         connectorId = config.connectorId;
161         refreshInterval = config.refreshInterval;
162         legacyFirmware = config.legacyFirmware;
163
164         if (connectorId.isEmpty()) {
165             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/offline.no-connector-id");
166             return;
167         } else if (!CONNECTOR_ID_PATTERN.matcher(connectorId).matches()) {
168             String msg = String.format("@text/offline.invalid-connector-id [ \"%s\" ]", connectorId);
169             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
170             return;
171         }
172
173         queryString = QUERY_BASE_STRING + connectorId;
174
175         updateStatus(ThingStatus.UNKNOWN);
176
177         scheduler.submit(this::startCommunication);
178     }
179
180     @Override
181     public void dispose() {
182         stopCommunication();
183     }
184
185     private synchronized void startCommunication() {
186         ElroConnectsBridgeConfiguration config = getConfigAs(ElroConnectsBridgeConfiguration.class);
187         InetAddress addr = null;
188
189         // First try with configured IP address if there is one
190         String ipAddress = config.ipAddress;
191         if (!ipAddress.isEmpty()) {
192             try {
193                 this.addr = InetAddress.getByName(ipAddress);
194                 addr = getAddr(false);
195             } catch (IOException e) {
196                 logger.warn("Unknown host for {}, trying to discover address", ipAddress);
197             }
198         }
199
200         // Then try broadcast to detect IP address if configured IP address did not work
201         if (addr == null) {
202             try {
203                 addr = getAddr(true);
204             } catch (IOException e) {
205                 String msg = String.format("@text/offline.find-ip-fail [ \"%s\" ]", connectorId);
206                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
207                 stopCommunication();
208                 return;
209             }
210         }
211
212         if (addr == null) {
213             String msg = String.format("@text/offline.find-ip-fail [ \"%s\" ]", connectorId);
214             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
215             stopCommunication();
216             return;
217         }
218
219         // Found valid IP address, update configuration with detected IP address
220         Configuration configuration = thing.getConfiguration();
221         configuration.put(CONFIG_IP_ADDRESS, addr.getHostAddress());
222         updateConfiguration(configuration);
223
224         String ctrlKey = this.ctrlKey;
225         if (ctrlKey.isEmpty()) {
226             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
227                     "@text/offline.communication-data-error");
228             stopCommunication();
229             return;
230         }
231
232         DatagramSocket socket;
233         try {
234             socket = createSocket(false);
235             this.socket = socket;
236         } catch (IOException e) {
237             String msg = String.format("@text/offline.communication-error [ \"%s\" ]", e.getMessage());
238             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
239             stopCommunication();
240             return;
241         }
242
243         ackPacket = new DatagramPacket(ACK, ACK.length, addr, PORT);
244
245         logger.debug("Connected to connector {} at {}:{}", connectorId, addr, PORT);
246
247         try {
248             // Start ELRO Connects listener. This listener will act on all messages coming from ELRO K1 Connector.
249             (new NamedThreadFactory(THREAD_NAME_PREFIX + thing.getUID().getAsString()).newThread(this::runElroEvents))
250                     .start();
251
252             keepAlive();
253
254             // First get status, then name. The status response contains the device types needed to instantiate correct
255             // classes.
256             getDeviceStatuses();
257             getDeviceNames();
258
259             syncScenes();
260             getCurrentScene();
261
262             updateStatus(ThingStatus.ONLINE);
263
264             // Enable discovery of devices
265             ElroConnectsDiscoveryService service = discoveryService;
266             if (service != null) {
267                 service.startBackgroundDiscovery();
268             }
269         } catch (IOException e) {
270             String msg = String.format("@text/offline.communication-error [ \"%s\" ]", e.getMessage());
271             restartCommunication(msg);
272             return;
273         }
274
275         scheduleSyncStatus();
276     }
277
278     /**
279      * Get the IP address and ctrl key of the connector by broadcasting message with connectorId. This should be used
280      * when initializing the connection. the ctrlKey and addr fields are set.
281      *
282      * @param broadcast, if true find address by broadcast, otherwise simply send to configured address to retrieve key
283      *            only
284      * @return IP address of connector
285      * @throws IOException
286      */
287     private @Nullable InetAddress getAddr(boolean broadcast) throws IOException {
288         try (DatagramSocket socket = createSocket(true)) {
289             String response = sendAndReceive(socket, queryString, broadcast);
290             Matcher keyMatcher = CTRL_KEY_PATTERN.matcher(response);
291             ctrlKey = keyMatcher.find() ? keyMatcher.group(1) : "";
292             logger.debug("Key: {}", ctrlKey);
293
294             return addr;
295         }
296     }
297
298     /**
299      * Send keep alive message.
300      *
301      * @throws IOException
302      */
303     private void keepAlive() throws IOException {
304         DatagramSocket socket = this.socket;
305         if (socket != null) {
306             logger.trace("Keep alive");
307             // Sending query string, so the connection with the K1 hub stays alive
308             awaitResponse(true);
309             send(socket, queryString, false);
310         } else {
311             restartCommunication("@text/offline.no-socket");
312         }
313     }
314
315     /**
316      * Cleanup socket when the communication with ELRO Connects connector is closed.
317      *
318      */
319     private synchronized void stopCommunication() {
320         ScheduledFuture<?> sync = syncFuture;
321         if (sync != null) {
322             sync.cancel(true);
323         }
324         syncFuture = null;
325
326         stopAwaitResponse();
327
328         DatagramSocket socket = this.socket;
329         if (socket != null && !socket.isClosed()) {
330             socket.close();
331         }
332         this.socket = null;
333
334         logger.debug("Communication stopped");
335     }
336
337     /**
338      * Close and restart communication with ELRO Connects system, to be called after error in communication.
339      *
340      * @param offlineMessage message for thing status
341      */
342     private synchronized void restartCommunication(String offlineMessage) {
343         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, offlineMessage);
344
345         stopCommunication();
346
347         if (!restart) {
348             logger.debug("Restart communication");
349
350             restart = true;
351             scheduler.schedule(this::startFromRestart, RESTART_DELAY_MS, TimeUnit.MILLISECONDS);
352         }
353     }
354
355     private synchronized void startFromRestart() {
356         restart = false;
357         if (ThingStatus.OFFLINE.equals(thing.getStatus())) {
358             startCommunication();
359         }
360     }
361
362     private DatagramSocket createSocket(boolean timeout) throws SocketException {
363         DatagramSocket socket = new DatagramSocket();
364         socket.setBroadcast(true);
365         if (timeout) {
366             socket.setSoTimeout(1000);
367         }
368         return socket;
369     }
370
371     /**
372      * Read messages received through UDP socket.
373      *
374      * @param socket
375      */
376     private void runElroEvents() {
377         DatagramSocket socket = this.socket;
378
379         if (socket != null) {
380             logger.debug("Listening for messages");
381
382             try {
383                 byte[] buffer = new byte[4096];
384                 DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
385                 while (!Thread.interrupted()) {
386                     String response = receive(socket, packet);
387                     processMessage(socket, response);
388                 }
389             } catch (IOException e) {
390                 String msg = String.format("@text/offline.communication-error [ \"%s\" ]", e.getMessage());
391                 restartCommunication(msg);
392             }
393         } else {
394             restartCommunication("@text/offline.no-socket");
395         }
396     }
397
398     /**
399      * Schedule regular queries to sync devices and scenes.
400      */
401     private void scheduleSyncStatus() {
402         syncFuture = scheduler.scheduleWithFixedDelay(() -> {
403             try {
404                 keepAlive();
405                 syncDevices();
406                 syncScenes();
407                 getCurrentScene();
408             } catch (IOException e) {
409                 String msg = String.format("@text/offline.communication-error [ \"%s\" ]", e.getMessage());
410                 restartCommunication(msg);
411             }
412         }, refreshInterval, refreshInterval, TimeUnit.SECONDS);
413     }
414
415     /**
416      * Process response received from K1 Hub and send acknowledgement through open socket.
417      *
418      * @param socket
419      * @param response
420      * @throws IOException
421      */
422     private void processMessage(DatagramSocket socket, String response) throws IOException {
423         if (!response.startsWith("{")) {
424             // Not a Json to interpret, just ignore
425             stopAwaitResponse();
426             return;
427         }
428         ElroConnectsMessage message;
429         String json = "";
430         try {
431             json = response.split("\\R")[0];
432             message = gsonIn.fromJson(json, ElroConnectsMessage.class);
433             sendAck(socket);
434         } catch (JsonSyntaxException ignore) {
435             logger.debug("Cannot decode, not a valid json: {}", json);
436             return;
437         }
438
439         if (message == null) {
440             return;
441         }
442
443         switch (message.getCmdId()) {
444             case ELRO_IGNORE_YES_NO:
445                 break;
446             case ELRO_REC_DEVICE_NAME:
447                 processDeviceNameMessage(message);
448                 break;
449             case ELRO_REC_DEVICE_STATUS:
450                 processDeviceStatusMessage(message);
451                 break;
452             case ELRO_REC_ALARM:
453                 processAlarmTriggerMessage(message);
454                 break;
455             case ELRO_REC_SCENE_NAME:
456                 processSceneNameMessage(message);
457                 break;
458             case ELRO_REC_SCENE_TYPE:
459                 processSceneTypeMessage(message);
460                 break;
461             case ELRO_REC_SCENE:
462                 processSceneMessage(message);
463                 break;
464             default:
465                 logger.debug("CmdId not implemented: {}", message.getCmdId());
466         }
467     }
468
469     private void processDeviceStatusMessage(ElroConnectsMessage message) {
470         int deviceId = message.getDeviceId();
471         String deviceStatus = message.getDeviceStatus();
472         if ("OVER".equals(deviceStatus)) {
473             // last message in series received
474             stopAwaitResponse();
475             return;
476         }
477
478         ElroConnectsDevice device = devices.get(deviceId);
479         device = (device == null) ? addDevice(message) : device;
480         if (device == null) {
481             // device type not recognized, could not be added
482             return;
483         }
484         device.setDeviceStatus(deviceStatus);
485
486         device.updateState();
487     }
488
489     private void processDeviceNameMessage(ElroConnectsMessage message) {
490         String answerContent = message.getAnswerContent();
491         if ("NAME_OVER".equals(answerContent)) {
492             // last message in series received
493             stopAwaitResponse();
494             return;
495         }
496         if (answerContent.length() <= 4) {
497             logger.debug("Could not decode answer {}", answerContent);
498             return;
499         }
500
501         int deviceId = Integer.parseInt(answerContent.substring(0, 4), 16);
502         String deviceName = ElroConnectsUtil.decode(answerContent.substring(4));
503         ElroConnectsDevice device = devices.get(deviceId);
504         if (device != null) {
505             device.setDeviceName(deviceName);
506             logger.debug("Device ID {} name: {}", deviceId, deviceName);
507         }
508     }
509
510     private void processSceneNameMessage(ElroConnectsMessage message) {
511         int sceneId = message.getSceneGroup();
512         String answerContent = message.getAnswerContent();
513         String sceneName;
514         if (sceneId > MAX_DEFAULT_SCENE) {
515             if (answerContent.length() < 44) {
516                 logger.debug("Could not decode answer {}", answerContent);
517                 return;
518             }
519             sceneName = ElroConnectsUtil.decode(answerContent.substring(6, 38));
520             scenes.put(sceneId, sceneName);
521             logger.debug("Scene ID {} name: {}", sceneId, sceneName);
522         }
523     }
524
525     private void processSceneTypeMessage(ElroConnectsMessage message) {
526         String sceneContent = message.getSceneContent();
527         if ("OVER".equals(sceneContent)) {
528             // last message in series received
529             stopAwaitResponse();
530
531             updateSceneOptions();
532         }
533     }
534
535     private void processSceneMessage(ElroConnectsMessage message) {
536         int sceneId = message.getSceneGroup();
537
538         currentScene = sceneId;
539
540         updateState(SCENE, new StringType(String.valueOf(currentScene)));
541     }
542
543     private void processAlarmTriggerMessage(ElroConnectsMessage message) {
544         String answerContent = message.getAnswerContent();
545         if (answerContent.length() < 10) {
546             logger.debug("Could not decode answer {}", answerContent);
547             return;
548         }
549
550         int deviceId = Integer.parseInt(answerContent.substring(6, 10), 16);
551
552         ElroConnectsDeviceHandler handler = deviceHandlers.get(deviceId);
553         if (handler != null) {
554             handler.triggerAlarm();
555         }
556         // Also trigger an alarm on the bridge, so the alarm also comes through when no thing for the device is
557         // configured
558         triggerChannel(ALARM, Integer.toString(deviceId));
559         logger.debug("Device ID {} alarm", deviceId);
560
561         if (answerContent.length() < 22) {
562             logger.debug("Could not get device status from alarm message for device {}", deviceId);
563             return;
564         }
565         String deviceStatus = answerContent.substring(14, 22);
566         ElroConnectsDevice device = devices.get(deviceId);
567         if (device != null) {
568             device.setDeviceStatus(deviceStatus);
569             device.updateState();
570         }
571     }
572
573     private @Nullable ElroConnectsDevice addDevice(ElroConnectsMessage message) {
574         int deviceId = message.getDeviceId();
575         String deviceType = message.getDeviceName();
576         ElroDeviceType type = TYPE_MAP.getOrDefault(deviceType, ElroDeviceType.DEFAULT);
577
578         ElroConnectsDevice device;
579         switch (type) {
580             case CO_ALARM:
581             case SM_ALARM:
582             case WT_ALARM:
583             case THERMAL_ALARM:
584                 device = new ElroConnectsDeviceGenericAlarm(deviceId, this);
585                 break;
586             case CXSM_ALARM:
587                 device = new ElroConnectsDeviceCxsmAlarm(deviceId, this);
588                 break;
589             case POWERSOCKET:
590                 device = new ElroConnectsDevicePowerSocket(deviceId, this);
591                 break;
592             case ENTRY_SENSOR:
593                 device = new ElroConnectsDeviceEntrySensor(deviceId, this);
594                 break;
595             case MOTION_SENSOR:
596                 device = new ElroConnectsDeviceMotionSensor(deviceId, this);
597                 break;
598             case TH_SENSOR:
599                 device = new ElroConnectsDeviceTemperatureSensor(deviceId, this);
600                 break;
601             default:
602                 logger.debug("Device type {} not supported", deviceType);
603                 return null;
604         }
605         device.setDeviceType(deviceType);
606         devices.put(deviceId, device);
607         return device;
608     }
609
610     /**
611      * Just before sending message, this method should be called to make sure we wait for all responses that are still
612      * expected to be received. The last response will be indicated by a token in the last response message.
613      *
614      * @param waitResponse true if we want to wait for response for next message to be sent before allowing subsequent
615      *            message
616      */
617     private void awaitResponse(boolean waitResponse) {
618         CompletableFuture<Boolean> waiting = awaitResponse;
619         if (waiting != null) {
620             try {
621                 logger.trace("Waiting for previous response before sending");
622                 waiting.get(RESPONSE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
623             } catch (InterruptedException | ExecutionException | TimeoutException ignore) {
624                 logger.trace("Wait for previous response timed out");
625             }
626         }
627         awaitResponse = waitResponse ? new CompletableFuture<>() : null;
628     }
629
630     /**
631      * This method is called when all responses on a request have been received.
632      */
633     private void stopAwaitResponse() {
634         CompletableFuture<Boolean> future = awaitResponse;
635         if (future != null) {
636             future.complete(true);
637         }
638         awaitResponse = null;
639     }
640
641     private void sendAck(DatagramSocket socket) throws IOException {
642         logger.debug("Send Ack: {}", ACK_STRING);
643         socket.send(ackPacket);
644     }
645
646     private String sendAndReceive(DatagramSocket socket, String query, boolean broadcast)
647             throws UnknownHostException, IOException {
648         send(socket, query, broadcast);
649         byte[] buffer = new byte[4096];
650         DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
651         return receive(socket, packet);
652     }
653
654     private void send(DatagramSocket socket, String query, boolean broadcast) throws IOException {
655         final InetAddress address = broadcast
656                 ? InetAddress.getByName(networkAddressService.getConfiguredBroadcastAddress())
657                 : addr;
658         if (address == null) {
659             if (broadcast) {
660                 restartCommunication("@text/offline.no-broadcast-address");
661             } else {
662                 restartCommunication("@text/offline.no-hub-address");
663             }
664             return;
665         }
666         logger.debug("Send: {}", query);
667         final byte[] queryBuffer = query.getBytes(StandardCharsets.UTF_8);
668         DatagramPacket queryPacket = new DatagramPacket(queryBuffer, queryBuffer.length, address, PORT);
669         socket.send(queryPacket);
670     }
671
672     private String receive(DatagramSocket socket, DatagramPacket packet) throws IOException {
673         socket.receive(packet);
674         String response = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8);
675         logger.debug("Received: {}", response);
676         addr = packet.getAddress();
677         return response;
678     }
679
680     /**
681      * Basic method to send an {@link ElroConnectsMessage} to the K1 hub.
682      *
683      * @param elroMessage
684      * @param waitResponse true if no new messages should be allowed to be sent before receiving the full response
685      * @throws IOException
686      */
687     private synchronized void sendElroMessage(ElroConnectsMessage elroMessage, boolean waitResponse)
688             throws IOException {
689         DatagramSocket socket = this.socket;
690         if (socket != null) {
691             String message = gsonOut.toJson(elroMessage);
692             awaitResponse(waitResponse);
693             send(socket, message, false);
694         } else {
695             throw new IOException("No socket");
696         }
697     }
698
699     /**
700      * Send device control command. The device command string various by device type. The calling method is responsible
701      * for creating the appropriate command string.
702      *
703      * @param deviceId
704      * @param deviceCommand ELRO Connects device command string
705      * @throws IOException
706      */
707     public void deviceControl(int deviceId, String deviceCommand) throws IOException {
708         String connectorId = this.connectorId;
709         String ctrlKey = this.ctrlKey;
710         logger.debug("Device control {}, status {}", deviceId, deviceCommand);
711         ElroConnectsMessage elroMessage = new ElroConnectsMessage(msgIdIncrement(), connectorId, ctrlKey,
712                 ELRO_DEVICE_CONTROL, legacyFirmware).withDeviceId(deviceId).withDeviceStatus(deviceCommand);
713         sendElroMessage(elroMessage, false);
714     }
715
716     public void renameDevice(int deviceId, String deviceName) throws IOException {
717         String connectorId = this.connectorId;
718         String ctrlKey = this.ctrlKey;
719         String encodedName = ElroConnectsUtil.encode(deviceName, 15);
720         encodedName = encodedName + ElroConnectsUtil.crc16(encodedName);
721         logger.debug("Rename device {} to {}", deviceId, deviceName);
722         ElroConnectsMessage elroMessage = new ElroConnectsMessage(msgIdIncrement(), connectorId, ctrlKey,
723                 ELRO_DEVICE_RENAME, legacyFirmware).withDeviceId(deviceId).withDeviceName(encodedName);
724         sendElroMessage(elroMessage, false);
725     }
726
727     private void joinDevice() throws IOException {
728         String connectorId = this.connectorId;
729         String ctrlKey = this.ctrlKey;
730         logger.debug("Put hub in join device mode");
731         ElroConnectsMessage elroMessage = new ElroConnectsMessage(msgIdIncrement(), connectorId, ctrlKey,
732                 ELRO_DEVICE_JOIN);
733         sendElroMessage(elroMessage, false);
734     }
735
736     private void cancelJoinDevice() throws IOException {
737         String connectorId = this.connectorId;
738         String ctrlKey = this.ctrlKey;
739         logger.debug("Cancel hub in join device mode");
740         ElroConnectsMessage elroMessage = new ElroConnectsMessage(msgIdIncrement(), connectorId, ctrlKey,
741                 ELRO_DEVICE_CANCEL_JOIN);
742         sendElroMessage(elroMessage, false);
743     }
744
745     private void removeDevice(int deviceId) throws IOException {
746         if (devices.remove(deviceId) == null) {
747             logger.debug("Device {} not known, cannot remove", deviceId);
748             return;
749         }
750         ThingHandler handler = getDeviceHandler(deviceId);
751         if (handler != null) {
752             handler.dispose();
753         }
754         String connectorId = this.connectorId;
755         String ctrlKey = this.ctrlKey;
756         logger.debug("Remove device {} from hub", deviceId);
757         ElroConnectsMessage elroMessage = new ElroConnectsMessage(msgIdIncrement(), connectorId, ctrlKey,
758                 ELRO_DEVICE_REMOVE, legacyFirmware).withDeviceId(deviceId);
759         sendElroMessage(elroMessage, false);
760     }
761
762     private void replaceDevice(int deviceId) throws IOException {
763         if (getDevice(deviceId) == null) {
764             logger.debug("Device {} not known, cannot replace", deviceId);
765             return;
766         }
767         String connectorId = this.connectorId;
768         String ctrlKey = this.ctrlKey;
769         logger.debug("Replace device {} in hub", deviceId);
770         ElroConnectsMessage elroMessage = new ElroConnectsMessage(msgIdIncrement(), connectorId, ctrlKey,
771                 ELRO_DEVICE_REPLACE, legacyFirmware).withDeviceId(deviceId);
772         sendElroMessage(elroMessage, false);
773     }
774
775     /**
776      * Send request to receive all device names.
777      *
778      * @throws IOException
779      */
780     private void getDeviceNames() throws IOException {
781         String connectorId = this.connectorId;
782         String ctrlKey = this.ctrlKey;
783         logger.debug("Get device names");
784         ElroConnectsMessage elroMessage = new ElroConnectsMessage(msgIdIncrement(), connectorId, ctrlKey,
785                 ELRO_GET_DEVICE_NAME).withDeviceId(0);
786         sendElroMessage(elroMessage, true);
787     }
788
789     /**
790      * Send request to receive all device statuses.
791      *
792      * @throws IOException
793      */
794     private void getDeviceStatuses() throws IOException {
795         String connectorId = this.connectorId;
796         String ctrlKey = this.ctrlKey;
797         logger.debug("Get all equipment status");
798         ElroConnectsMessage elroMessage = new ElroConnectsMessage(msgIdIncrement(), connectorId, ctrlKey,
799                 ELRO_GET_DEVICE_STATUSES);
800         sendElroMessage(elroMessage, true);
801     }
802
803     /**
804      * Send request to sync all devices statuses.
805      *
806      * @throws IOException
807      */
808     private void syncDevices() throws IOException {
809         String connectorId = this.connectorId;
810         String ctrlKey = this.ctrlKey;
811         logger.debug("Sync device status");
812         ElroConnectsMessage elroMessage = new ElroConnectsMessage(msgIdIncrement(), connectorId, ctrlKey,
813                 ELRO_SYNC_DEVICES).withDeviceStatus(SYNC_COMMAND);
814         sendElroMessage(elroMessage, true);
815     }
816
817     /**
818      * Send request to get the currently selected scene.
819      *
820      * @throws IOException
821      */
822     private void getCurrentScene() throws IOException {
823         String connectorId = this.connectorId;
824         String ctrlKey = this.ctrlKey;
825         logger.debug("Get current scene");
826         ElroConnectsMessage elroMessage = new ElroConnectsMessage(msgIdIncrement(), connectorId, ctrlKey,
827                 ELRO_GET_SCENE);
828         sendElroMessage(elroMessage, true);
829     }
830
831     /**
832      * Send message to set the current scene.
833      *
834      * @throws IOException
835      */
836     private void selectScene(int scene) throws IOException {
837         String connectorId = this.connectorId;
838         String ctrlKey = this.ctrlKey;
839         logger.debug("Select scene {}", scene);
840         ElroConnectsMessage elroMessage = new ElroConnectsMessage(msgIdIncrement(), connectorId, ctrlKey,
841                 ELRO_SELECT_SCENE, legacyFirmware).withSceneType(scene);
842         sendElroMessage(elroMessage, false);
843     }
844
845     /**
846      * Send request to sync all scenes.
847      *
848      * @throws IOException
849      */
850     private void syncScenes() throws IOException {
851         String connectorId = this.connectorId;
852         String ctrlKey = this.ctrlKey;
853         logger.debug("Sync scenes");
854         ElroConnectsMessage elroMessage = new ElroConnectsMessage(msgIdIncrement(), connectorId, ctrlKey,
855                 ELRO_SYNC_SCENES, legacyFirmware).withSceneGroup(0).withSceneContent(SYNC_COMMAND)
856                         .withAnswerContent(SYNC_COMMAND);
857         sendElroMessage(elroMessage, true);
858     }
859
860     @Override
861     public void handleCommand(ChannelUID channelUID, Command command) {
862         logger.debug("Channel {}, command {}, type {}", channelUID, command, command.getClass());
863         try {
864             if (SCENE.equals(channelUID.getId())) {
865                 if (command instanceof RefreshType) {
866                     updateState(SCENE, new StringType(String.valueOf(currentScene)));
867                 } else if (command instanceof StringType) {
868                     try {
869                         selectScene(Integer.valueOf(((StringType) command).toString()));
870                     } catch (NumberFormatException nfe) {
871                         logger.debug("Cannot interpret scene command {}", command);
872                     }
873                 }
874             }
875         } catch (IOException e) {
876             String msg = String.format("@text/offline.communication-error [ \"%s\" ]", e.getMessage());
877             restartCommunication(msg);
878         }
879     }
880
881     /**
882      * We do not get scene delete messages, therefore call this method before requesting list of scenes to clear list of
883      * scenes.
884      */
885     private void resetScenes() {
886         scenes.clear();
887         scenes.putAll(DEFAULT_SCENES);
888
889         updateSceneOptions();
890     }
891
892     /**
893      * Update state option list for scene selection channel.
894      */
895     private void updateSceneOptions() {
896         // update the command scene command options
897         List<StateOption> stateOptionList = new ArrayList<>();
898         scenes.forEach((id, scene) -> {
899             StateOption option = new StateOption(Integer.toString(id), scene);
900             stateOptionList.add(option);
901         });
902         logger.trace("Scenes: {}", stateOptionList);
903
904         Channel channel = thing.getChannel(SCENE);
905         if (channel != null) {
906             ChannelUID channelUID = channel.getUID();
907             StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withReadOnly(false)
908                     .withOptions(stateOptionList).build().toStateDescription();
909             stateDescriptionProvider.setDescription(channelUID, stateDescription);
910         }
911     }
912
913     /**
914      * Messages need to be sent with consecutive id's. Increment the msgId field and rotate at max unsigned short.
915      *
916      * @return new message id
917      */
918     private int msgIdIncrement() {
919         return Short.toUnsignedInt(msgId++);
920     }
921
922     /**
923      * Set the {@link ElroConnectsDeviceHandler} for the device with deviceId, should be called from the thing handler
924      * when initializing the thing.
925      *
926      * @param deviceId
927      * @param handler
928      */
929     public void setDeviceHandler(int deviceId, ElroConnectsDeviceHandler handler) {
930         deviceHandlers.put(deviceId, handler);
931     }
932
933     /**
934      * Unset the {@link ElroConnectsDeviceHandler} for the device with deviceId, should be called from the thing handler
935      * when disposing the thing.
936      *
937      * @param deviceId
938      * @param handler
939      */
940     public void unsetDeviceHandler(int deviceId, ElroConnectsDeviceHandler handler) {
941         deviceHandlers.remove(deviceId, handler);
942     }
943
944     public @Nullable ElroConnectsDeviceHandler getDeviceHandler(int deviceId) {
945         return deviceHandlers.get(deviceId);
946     }
947
948     public String getConnectorId() {
949         return connectorId;
950     }
951
952     public @Nullable ElroConnectsDevice getDevice(int deviceId) {
953         return devices.get(deviceId);
954     }
955
956     /**
957      * Get full list of devices connected to the K1 hub. This can be used by the {@link ElroConnectsDiscoveryService} to
958      * scan for devices connected to the K1 hub.
959      *
960      * @return devices
961      */
962     public Map<Integer, ElroConnectsDevice> getDevices() {
963         return devices;
964     }
965
966     @Override
967     public Collection<Class<? extends ThingHandlerService>> getServices() {
968         return Collections.singleton(ElroConnectsDiscoveryService.class);
969     }
970
971     public Map<Integer, String> listDevicesFromConsole() {
972         return devices.entrySet().stream()
973                 .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue().getDeviceName()));
974     }
975
976     public void refreshFromConsole() {
977         try {
978             keepAlive();
979             getDeviceStatuses();
980             getDeviceNames();
981         } catch (IOException e) {
982             String msg = String.format("@text/offline.communication-error [ \"%s\" ]", e.getMessage());
983             restartCommunication(msg);
984         }
985     }
986
987     public void joinDeviceFromConsole() {
988         try {
989             joinDevice();
990         } catch (IOException e) {
991             String msg = String.format("@text/offline.communication-error [ \"%s\" ]", e.getMessage());
992             restartCommunication(msg);
993         }
994     }
995
996     public void cancelJoinDeviceFromConsole() {
997         try {
998             cancelJoinDevice();
999         } catch (IOException e) {
1000             String msg = String.format("@text/offline.communication-error [ \"%s\" ]", e.getMessage());
1001             restartCommunication(msg);
1002         }
1003     }
1004
1005     public boolean renameDeviceFromConsole(int deviceId, String deviceName) {
1006         if (getDevice(deviceId) == null) {
1007             return false;
1008         }
1009         try {
1010             renameDevice(deviceId, deviceName);
1011         } catch (IOException e) {
1012             String msg = String.format("@text/offline.communication-error [ \"%s\" ]", e.getMessage());
1013             restartCommunication(msg);
1014         }
1015         return true;
1016     }
1017
1018     public boolean removeDeviceFromConsole(int deviceId) {
1019         if (getDevice(deviceId) == null) {
1020             return false;
1021         }
1022         try {
1023             removeDevice(deviceId);
1024         } catch (IOException e) {
1025             String msg = String.format("@text/offline.communication-error [ \"%s\" ]", e.getMessage());
1026             restartCommunication(msg);
1027         }
1028         return true;
1029     }
1030
1031     public boolean replaceDeviceFromConsole(int deviceId) {
1032         if (getDevice(deviceId) == null) {
1033             return false;
1034         }
1035         try {
1036             replaceDevice(deviceId);
1037         } catch (IOException e) {
1038             String msg = String.format("@text/offline.communication-error [ \"%s\" ]", e.getMessage());
1039             restartCommunication(msg);
1040         }
1041         return true;
1042     }
1043
1044     public void setDiscoveryService(ElroConnectsDiscoveryService discoveryService) {
1045         this.discoveryService = discoveryService;
1046     }
1047 }