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