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