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