]> git.basschouten.com Git - openhab-addons.git/blob
410f0312ae4f0dbb941b52ca6f31aad6f2818927
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.amazonechocontrol.internal;
14
15 import java.io.IOException;
16 import java.net.HttpCookie;
17 import java.net.URI;
18 import java.net.URISyntaxException;
19 import java.nio.ByteBuffer;
20 import java.nio.charset.StandardCharsets;
21 import java.util.ArrayList;
22 import java.util.Arrays;
23 import java.util.Date;
24 import java.util.List;
25 import java.util.Timer;
26 import java.util.TimerTask;
27 import java.util.UUID;
28 import java.util.concurrent.Future;
29 import java.util.concurrent.ThreadLocalRandom;
30
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.eclipse.jetty.util.ssl.SslContextFactory;
34 import org.eclipse.jetty.websocket.api.Session;
35 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
36 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
37 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
38 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
39 import org.eclipse.jetty.websocket.api.annotations.WebSocket;
40 import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
41 import org.eclipse.jetty.websocket.client.WebSocketClient;
42 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPushCommand;
43 import org.slf4j.Logger;
44 import org.slf4j.LoggerFactory;
45
46 import com.google.gson.Gson;
47 import com.google.gson.JsonSyntaxException;
48
49 /**
50  * The {@link WebSocketConnection} encapsulate the Web Socket connection to the amazon server.
51  * The code is based on
52  * https://github.com/Apollon77/alexa-remote/blob/master/alexa-wsmqtt.js
53  *
54  * @author Michael Geramb - Initial contribution
55  * @author Ingo Fischer - (https://github.com/Apollon77/alexa-remote/blob/master/alexa-wsmqtt.js)
56  */
57 @NonNullByDefault
58 public class WebSocketConnection {
59     private final Logger logger = LoggerFactory.getLogger(WebSocketConnection.class);
60     private final Gson gson = new Gson();
61     private final WebSocketClient webSocketClient;
62     private final IWebSocketCommandHandler webSocketCommandHandler;
63     private final AmazonEchoControlWebSocket amazonEchoControlWebSocket;
64
65     private @Nullable Session session;
66     private @Nullable Timer pingTimer;
67     private @Nullable Timer pongTimeoutTimer;
68     private @Nullable Future<?> sessionFuture;
69
70     private boolean closed;
71
72     public WebSocketConnection(String amazonSite, List<HttpCookie> sessionCookies,
73             IWebSocketCommandHandler webSocketCommandHandler) throws IOException {
74         this.webSocketCommandHandler = webSocketCommandHandler;
75         amazonEchoControlWebSocket = new AmazonEchoControlWebSocket();
76         webSocketClient = new WebSocketClient(new SslContextFactory.Client());
77         try {
78             String host;
79             if (amazonSite.equalsIgnoreCase("amazon.com")) {
80                 host = "dp-gw-na-js." + amazonSite;
81             } else {
82                 host = "dp-gw-na." + amazonSite;
83             }
84
85             String deviceSerial = "";
86             List<HttpCookie> cookiesForWs = new ArrayList<>();
87             for (HttpCookie cookie : sessionCookies) {
88                 if (cookie.getName().equals("ubid-acbde")) {
89                     deviceSerial = cookie.getValue();
90                 }
91                 // Clone the cookie without the security attribute, because the web socket implementation ignore secure
92                 // cookies
93                 String value = cookie.getValue().replaceAll("^\"|\"$", "");
94                 HttpCookie cookieForWs = new HttpCookie(cookie.getName(), value);
95                 cookiesForWs.add(cookieForWs);
96             }
97             deviceSerial += "-" + new Date().getTime();
98             URI uri;
99
100             uri = new URI("wss://" + host + "/?x-amz-device-type=ALEGCNGL9K0HM&x-amz-device-serial=" + deviceSerial);
101
102             try {
103                 webSocketClient.start();
104             } catch (Exception e) {
105                 logger.warn("Web socket start failed", e);
106                 throw new IOException("Web socket start failed");
107             }
108
109             ClientUpgradeRequest request = new ClientUpgradeRequest();
110             request.setHeader("Host", host);
111             request.setHeader("Origin", "alexa." + amazonSite);
112             request.setCookies(cookiesForWs);
113
114             initPongTimeoutTimer();
115
116             sessionFuture = webSocketClient.connect(amazonEchoControlWebSocket, uri, request);
117         } catch (URISyntaxException e) {
118             logger.debug("Initialize web socket failed", e);
119         }
120     }
121
122     private void setSession(Session session) {
123         this.session = session;
124         logger.debug("Web Socket session started");
125         Timer pingTimer = new Timer();
126         this.pingTimer = pingTimer;
127         pingTimer.schedule(new TimerTask() {
128
129             @Override
130             public void run() {
131                 amazonEchoControlWebSocket.sendPing();
132             }
133         }, 180000, 180000);
134     }
135
136     public boolean isClosed() {
137         return closed;
138     }
139
140     public void close() {
141         closed = true;
142         Timer pingTimer = this.pingTimer;
143         if (pingTimer != null) {
144             pingTimer.cancel();
145         }
146         clearPongTimeoutTimer();
147         Session session = this.session;
148         this.session = null;
149         if (session != null) {
150             try {
151                 session.close();
152             } catch (Exception e) {
153                 logger.debug("Closing session failed", e);
154             }
155         }
156         logger.trace("Connect future = {}", sessionFuture);
157         final Future<?> sessionFuture = this.sessionFuture;
158         if (sessionFuture != null && !sessionFuture.isDone()) {
159             sessionFuture.cancel(true);
160         }
161         try {
162             webSocketClient.stop();
163         } catch (InterruptedException e) {
164             // Just ignore
165         } catch (Exception e) {
166             logger.debug("Stopping websocket failed", e);
167         }
168         webSocketClient.destroy();
169     }
170
171     void clearPongTimeoutTimer() {
172         Timer pongTimeoutTimer = this.pongTimeoutTimer;
173         this.pongTimeoutTimer = null;
174         if (pongTimeoutTimer != null) {
175             logger.trace("Cancelling pong timeout");
176             pongTimeoutTimer.cancel();
177         }
178     }
179
180     void initPongTimeoutTimer() {
181         clearPongTimeoutTimer();
182         Timer pongTimeoutTimer = new Timer();
183         this.pongTimeoutTimer = pongTimeoutTimer;
184         logger.trace("Scheduling pong timeout");
185         pongTimeoutTimer.schedule(new TimerTask() {
186
187             @Override
188             public void run() {
189                 logger.trace("Pong timeout reached. Closing connection.");
190                 close();
191             }
192         }, 60000);
193     }
194
195     @WebSocket(maxTextMessageSize = 64 * 1024, maxBinaryMessageSize = 64 * 1024)
196     public class AmazonEchoControlWebSocket {
197         int msgCounter = -1;
198         int messageId;
199
200         AmazonEchoControlWebSocket() {
201             this.messageId = ThreadLocalRandom.current().nextInt(0, Short.MAX_VALUE);
202         }
203
204         void sendMessage(String message) {
205             sendMessage(message.getBytes(StandardCharsets.UTF_8));
206         }
207
208         void sendMessageHex(String message) {
209             sendMessage(hexStringToByteArray(message));
210         }
211
212         void sendMessage(byte[] buffer) {
213             try {
214                 logger.debug("Send message with length {}", buffer.length);
215                 Session session = WebSocketConnection.this.session;
216                 if (session != null) {
217                     session.getRemote().sendBytes(ByteBuffer.wrap(buffer));
218                 }
219             } catch (IOException e) {
220                 logger.debug("Send message failed", e);
221                 WebSocketConnection.this.close();
222             }
223         }
224
225         byte[] hexStringToByteArray(String str) {
226             byte[] bytes = new byte[str.length() / 2];
227             for (int i = 0; i < bytes.length; i++) {
228                 String strValue = str.substring(2 * i, 2 * i + 2);
229                 bytes[i] = (byte) Integer.parseInt(strValue, 16);
230             }
231             return bytes;
232         }
233
234         long readHex(byte[] data, int index, int length) {
235             String str = readString(data, index, length);
236             if (str.startsWith("0x")) {
237                 str = str.substring(2);
238             }
239             return Long.parseLong(str, 16);
240         }
241
242         String readString(byte[] data, int index, int length) {
243             return new String(data, index, length, StandardCharsets.UTF_8);
244         }
245
246         class Message {
247             String service = "";
248             Content content = new Content();
249             String contentTune = "";
250             String messageType = "";
251             long channel;
252             long checksum;
253             long messageId;
254             String moreFlag = "";
255             long seq;
256         }
257
258         class Content {
259             String messageType = "";
260             String protocolVersion = "";
261             String connectionUUID = "";
262             long established;
263             long timestampINI;
264             long timestampACK;
265             String subMessageType = "";
266             long channel;
267             String destinationIdentityUrn = "";
268             String deviceIdentityUrn = "";
269             @Nullable
270             String payload;
271             byte[] payloadData = new byte[0];
272             @Nullable
273             JsonPushCommand pushCommand;
274         }
275
276         Message parseIncomingMessage(byte[] data) {
277             int idx = 0;
278             Message message = new Message();
279             message.service = readString(data, data.length - 4, 4);
280
281             if (message.service.equals("TUNE")) {
282                 message.checksum = readHex(data, idx, 10);
283                 idx += 11; // 10 + delimiter;
284                 int contentLength = (int) readHex(data, idx, 10);
285                 idx += 11; // 10 + delimiter;
286                 message.contentTune = readString(data, idx, contentLength - 4 - idx);
287             } else if (message.service.equals("FABE")) {
288                 message.messageType = readString(data, idx, 3);
289                 idx += 4;
290                 message.channel = readHex(data, idx, 10);
291                 idx += 11; // 10 + delimiter;
292                 message.messageId = readHex(data, idx, 10);
293                 idx += 11; // 10 + delimiter;
294                 message.moreFlag = readString(data, idx, 1);
295                 idx += 2; // 1 + delimiter;
296                 message.seq = readHex(data, idx, 10);
297                 idx += 11; // 10 + delimiter;
298                 message.checksum = readHex(data, idx, 10);
299                 idx += 11; // 10 + delimiter;
300
301                 // currently not used: long contentLength = readHex(data, idx, 10);
302                 idx += 11; // 10 + delimiter;
303
304                 message.content.messageType = readString(data, idx, 3);
305                 idx += 4;
306
307                 if (message.channel == 0x361) { // GW_HANDSHAKE_CHANNEL
308                     if (message.content.messageType.equals("ACK")) {
309                         int length = (int) readHex(data, idx, 10);
310                         idx += 11; // 10 + delimiter;
311                         message.content.protocolVersion = readString(data, idx, length);
312                         idx += length + 1;
313                         length = (int) readHex(data, idx, 10);
314                         idx += 11; // 10 + delimiter;
315                         message.content.connectionUUID = readString(data, idx, length);
316                         idx += length + 1;
317                         message.content.established = readHex(data, idx, 10);
318                         idx += 11; // 10 + delimiter;
319                         message.content.timestampINI = readHex(data, idx, 18);
320                         idx += 19; // 18 + delimiter;
321                         message.content.timestampACK = readHex(data, idx, 18);
322                         idx += 19; // 18 + delimiter;
323                     }
324                 } else if (message.channel == 0x362) { // GW_CHANNEL
325                     if (message.content.messageType.equals("GWM")) {
326                         message.content.subMessageType = readString(data, idx, 3);
327                         idx += 4;
328                         message.content.channel = readHex(data, idx, 10);
329                         idx += 11; // 10 + delimiter;
330
331                         if (message.content.channel == 0xb479) { // DEE_WEBSITE_MESSAGING
332                             int length = (int) readHex(data, idx, 10);
333                             idx += 11; // 10 + delimiter;
334                             message.content.destinationIdentityUrn = readString(data, idx, length);
335                             idx += length + 1;
336
337                             length = (int) readHex(data, idx, 10);
338                             idx += 11; // 10 + delimiter;
339                             String idData = readString(data, idx, length);
340                             idx += length + 1;
341
342                             String[] idDataElements = idData.split(" ", 2);
343                             message.content.deviceIdentityUrn = idDataElements[0];
344                             String payload = null;
345                             if (idDataElements.length == 2) {
346                                 payload = idDataElements[1];
347                             }
348                             if (payload == null) {
349                                 payload = readString(data, idx, data.length - 4 - idx);
350                             }
351                             if (!payload.isEmpty()) {
352                                 try {
353                                     message.content.pushCommand = gson.fromJson(payload, JsonPushCommand.class);
354                                 } catch (JsonSyntaxException e) {
355                                     logger.info("Parsing json failed, illegal JSON: {}", payload, e);
356                                 }
357                             }
358                             message.content.payload = payload;
359                         }
360                     }
361                 } else if (message.channel == 0x65) { // CHANNEL_FOR_HEARTBEAT
362                     idx -= 1; // no delimiter!
363                     message.content.payloadData = Arrays.copyOfRange(data, idx, data.length - 4);
364                 }
365             }
366             return message;
367         }
368
369         @OnWebSocketConnect
370         public void onWebSocketConnect(@Nullable Session session) {
371             if (session != null) {
372                 this.msgCounter = -1;
373                 setSession(session);
374                 sendMessage("0x99d4f71a 0x0000001d A:HTUNE");
375             } else {
376                 logger.debug("Web Socket connect without session");
377             }
378         }
379
380         @OnWebSocketMessage
381         public void onWebSocketBinary(byte @Nullable [] data, int offset, int len) {
382             if (data == null) {
383                 return;
384             }
385             this.msgCounter++;
386             if (this.msgCounter == 0) {
387                 sendMessage(
388                         "0xa6f6a951 0x0000009c {\"protocolName\":\"A:H\",\"parameters\":{\"AlphaProtocolHandler.receiveWindowSize\":\"16\",\"AlphaProtocolHandler.maxFragmentSize\":\"16000\"}}TUNE");
389                 sendMessage(encodeGWHandshake());
390             } else if (this.msgCounter == 1) {
391                 sendMessage(encodeGWRegister());
392                 sendPing();
393             } else {
394                 byte[] buffer = data;
395                 if (offset > 0 || len != buffer.length) {
396                     buffer = Arrays.copyOfRange(data, offset, offset + len);
397                 }
398                 try {
399                     Message message = parseIncomingMessage(buffer);
400                     if (message.service.equals("FABE") && message.content.messageType.equals("PON")
401                             && message.content.payloadData.length > 0) {
402                         logger.debug("Pong received");
403                         WebSocketConnection.this.clearPongTimeoutTimer();
404                         return;
405                     } else {
406                         JsonPushCommand pushCommand = message.content.pushCommand;
407                         logger.debug("Message received: {}", message.content.payload);
408                         if (pushCommand != null) {
409                             webSocketCommandHandler.webSocketCommandReceived(pushCommand);
410                         }
411                         return;
412                     }
413                 } catch (Exception e) {
414                     logger.debug("Handling of push notification failed", e);
415                 }
416             }
417         }
418
419         @OnWebSocketMessage
420         public void onWebSocketText(@Nullable String message) {
421             logger.trace("Received text message: '{}'", message);
422         }
423
424         @OnWebSocketClose
425         public void onWebSocketClose(int code, @Nullable String reason) {
426             logger.info("Web Socket close {}. Reason: {}", code, reason);
427             WebSocketConnection.this.close();
428         }
429
430         @OnWebSocketError
431         public void onWebSocketError(@Nullable Throwable error) {
432             logger.info("Web Socket error", error);
433             if (!closed) {
434                 WebSocketConnection.this.close();
435             }
436         }
437
438         public void sendPing() {
439             logger.debug("Send Ping");
440             WebSocketConnection.this.initPongTimeoutTimer();
441             sendMessage(encodePing());
442         }
443
444         String encodeNumber(long val) {
445             return encodeNumber(val, 8);
446         }
447
448         String encodeNumber(long val, int len) {
449             String str = Long.toHexString(val);
450             if (str.length() > len) {
451                 str = str.substring(str.length() - len);
452             }
453             while (str.length() < len) {
454                 str = '0' + str;
455             }
456             return "0x" + str;
457         }
458
459         long computeBits(long input, long len) {
460             long lenCounter = len;
461             long value;
462             for (value = toUnsignedInt(input); 0 != lenCounter && 0 != value;) {
463                 value = (long) Math.floor(value / 2);
464                 lenCounter--;
465             }
466             return value;
467         }
468
469         long toUnsignedInt(long value) {
470             long result = value;
471             if (0 > value) {
472                 result = 4294967295L + value + 1;
473             }
474             return result;
475         }
476
477         int computeChecksum(byte[] data, int exclusionStart, int exclusionEnd) {
478             if (exclusionEnd < exclusionStart) {
479                 return 0;
480             }
481             long overflow;
482             long sum;
483             int index;
484             for (overflow = 0, sum = 0, index = 0; index < data.length; index++) {
485                 if (index != exclusionStart) {
486                     sum += toUnsignedInt((data[index] & 0xFF) << ((index & 3 ^ 3) << 3));
487                     overflow += computeBits(sum, 32);
488                     sum = toUnsignedInt((int) sum & (int) 4294967295L);
489
490                 } else {
491                     index = exclusionEnd - 1;
492                 }
493             }
494             while (overflow != 0) {
495                 sum += overflow;
496                 overflow = computeBits(sum, 32);
497                 sum = (int) sum & (int) 4294967295L;
498             }
499             long value = toUnsignedInt(sum);
500             return (int) value;
501         }
502
503         byte[] encodeGWHandshake() {
504             // pubrelBuf = new Buffer('MSG 0x00000361 0x0e414e45 f 0x00000001 0xd7c62f29 0x0000009b INI 0x00000003 1.0
505             // 0x00000024 ff1c4525-c036-4942-bf6c-a098755ac82f 0x00000164d106ce6b END FABE');
506             this.messageId++;
507             String msg = "MSG 0x00000361 "; // Message-type and Channel = GW_HANDSHAKE_CHANNEL;
508             msg += this.encodeNumber(this.messageId) + " f 0x00000001 ";
509             int checkSumStart = msg.length();
510             msg += "0x00000000 "; // Checksum!
511             int checkSumEnd = msg.length();
512             msg += "0x0000009b "; // length content
513             msg += "INI 0x00000003 1.0 0x00000024 "; // content part 1
514             msg += UUID.randomUUID().toString();
515             msg += ' ';
516             msg += this.encodeNumber(new Date().getTime(), 16);
517             msg += " END FABE";
518             // msg = "MSG 0x00000361 0x0e414e45 f 0x00000001 0xd7c62f29 0x0000009b INI 0x00000003 1.0 0x00000024
519             // ff1c4525-c036-4942-bf6c-a098755ac82f 0x00000164d106ce6b END FABE";
520             byte[] completeBuffer = msg.getBytes(StandardCharsets.US_ASCII);
521
522             int checksum = this.computeChecksum(completeBuffer, checkSumStart, checkSumEnd);
523             String checksumHex = encodeNumber(checksum);
524             byte[] checksumBuf = checksumHex.getBytes(StandardCharsets.US_ASCII);
525             System.arraycopy(checksumBuf, 0, completeBuffer, checkSumStart, checksumBuf.length);
526
527             return completeBuffer;
528         }
529
530         byte[] encodeGWRegister() {
531             // pubrelBuf = new Buffer('MSG 0x00000362 0x0e414e46 f 0x00000001 0xf904b9f5 0x00000109 GWM MSG 0x0000b479
532             // 0x0000003b urn:tcomm-endpoint:device:deviceType:0:deviceSerialNumber:0 0x00000041
533             // urn:tcomm-endpoint:service:serviceName:DeeWebsiteMessagingService
534             // {"command":"REGISTER_CONNECTION"}FABE');
535             this.messageId++;
536             String msg = "MSG 0x00000362 "; // Message-type and Channel = GW_CHANNEL;
537             msg += this.encodeNumber(this.messageId) + " f 0x00000001 ";
538             int checkSumStart = msg.length();
539             msg += "0x00000000 "; // Checksum!
540             int checkSumEnd = msg.length();
541             msg += "0x00000109 "; // length content
542             msg += "GWM MSG 0x0000b479 0x0000003b urn:tcomm-endpoint:device:deviceType:0:deviceSerialNumber:0 0x00000041 urn:tcomm-endpoint:service:serviceName:DeeWebsiteMessagingService {\"command\":\"REGISTER_CONNECTION\"}FABE";
543
544             byte[] completeBuffer = msg.getBytes(StandardCharsets.US_ASCII);
545
546             int checksum = this.computeChecksum(completeBuffer, checkSumStart, checkSumEnd);
547
548             String checksumHex = encodeNumber(checksum);
549             byte[] checksumBuf = checksumHex.getBytes(StandardCharsets.US_ASCII);
550             System.arraycopy(checksumBuf, 0, completeBuffer, checkSumStart, checksumBuf.length);
551
552             String test = readString(completeBuffer, 0, completeBuffer.length);
553             test.toString();
554             return completeBuffer;
555         }
556
557         void encode(byte[] data, long b, int offset, int len) {
558             for (int index = 0; index < len; index++) {
559                 data[index + offset] = (byte) (b >> 8 * (len - 1 - index) & 255);
560             }
561         }
562
563         byte[] encodePing() {
564             // MSG 0x00000065 0x0e414e47 f 0x00000001 0xbc2fbb5f 0x00000062
565             this.messageId++;
566             String msg = "MSG 0x00000065 "; // Message-type and Channel = CHANNEL_FOR_HEARTBEAT;
567             msg += this.encodeNumber(this.messageId) + " f 0x00000001 ";
568             int checkSumStart = msg.length();
569             msg += "0x00000000 "; // Checksum!
570             int checkSumEnd = msg.length();
571             msg += "0x00000062 "; // length content
572
573             byte[] completeBuffer = new byte[0x62];
574             byte[] startBuffer = msg.getBytes(StandardCharsets.US_ASCII);
575
576             System.arraycopy(startBuffer, 0, completeBuffer, 0, startBuffer.length);
577
578             byte[] header = "PIN".getBytes(StandardCharsets.US_ASCII);
579             byte[] payload = "Regular".getBytes(StandardCharsets.US_ASCII); // g = h.length
580             byte[] bufferPing = new byte[header.length + 4 + 8 + 4 + 2 * payload.length];
581             int idx = 0;
582             System.arraycopy(header, 0, bufferPing, 0, header.length);
583             idx += header.length;
584             encode(bufferPing, 0, idx, 4);
585             idx += 4;
586             encode(bufferPing, new Date().getTime(), idx, 8);
587             idx += 8;
588             encode(bufferPing, payload.length, idx, 4);
589             idx += 4;
590
591             for (int q = 0; q < payload.length; q++) {
592                 bufferPing[idx + q * 2] = (byte) 0;
593                 bufferPing[idx + q * 2 + 1] = payload[q];
594             }
595             System.arraycopy(bufferPing, 0, completeBuffer, startBuffer.length, bufferPing.length);
596
597             byte[] buf2End = "FABE".getBytes(StandardCharsets.US_ASCII);
598             System.arraycopy(buf2End, 0, completeBuffer, startBuffer.length + bufferPing.length, buf2End.length);
599
600             int checksum = this.computeChecksum(completeBuffer, checkSumStart, checkSumEnd);
601             String checksumHex = encodeNumber(checksum);
602             byte[] checksumBuf = checksumHex.getBytes(StandardCharsets.US_ASCII);
603             System.arraycopy(checksumBuf, 0, completeBuffer, checkSumStart, checksumBuf.length);
604             return completeBuffer;
605         }
606     }
607 }