2 * Copyright (c) 2010-2020 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.amazonechocontrol.internal;
15 import java.io.IOException;
16 import java.net.HttpCookie;
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;
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;
47 import com.google.gson.Gson;
48 import com.google.gson.JsonSyntaxException;
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
55 * @author Michael Geramb - Initial contribution
56 * @author Ingo Fischer - (https://github.com/Apollon77/alexa-remote/blob/master/alexa-wsmqtt.js)
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;
66 private @Nullable Session session;
67 private @Nullable Timer pingTimer;
68 private @Nullable Timer pongTimeoutTimer;
69 private @Nullable Future<?> sessionFuture;
71 private boolean closed;
73 public WebSocketConnection(String amazonSite, List<HttpCookie> sessionCookies,
74 IWebSocketCommandHandler webSocketCommandHandler) throws IOException {
75 this.webSocketCommandHandler = webSocketCommandHandler;
76 amazonEchoControlWebSocket = new AmazonEchoControlWebSocket();
78 SslContextFactory sslContextFactory = new SslContextFactory();
79 webSocketClient = new WebSocketClient(sslContextFactory);
82 if (StringUtils.equalsIgnoreCase(amazonSite, "amazon.com")) {
83 host = "dp-gw-na-js." + amazonSite;
85 host = "dp-gw-na." + amazonSite;
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();
94 // Clone the cookie without the security attribute, because the web socket implementation ignore secure
96 String value = cookie.getValue().replaceAll("^\"|\"$", "");
97 HttpCookie cookieForWs = new HttpCookie(cookie.getName(), value);
98 cookiesForWs.add(cookieForWs);
100 deviceSerial += "-" + new Date().getTime();
103 uri = new URI("wss://" + host + "/?x-amz-device-type=ALEGCNGL9K0HM&x-amz-device-serial=" + deviceSerial);
106 webSocketClient.start();
107 } catch (Exception e) {
108 logger.warn("Web socket start failed", e);
109 throw new IOException("Web socket start failed");
112 ClientUpgradeRequest request = new ClientUpgradeRequest();
113 request.setHeader("Host", host);
114 request.setHeader("Origin", "alexa." + amazonSite);
115 request.setCookies(cookiesForWs);
117 initPongTimeoutTimer();
119 sessionFuture = webSocketClient.connect(amazonEchoControlWebSocket, uri, request);
120 } catch (URISyntaxException e) {
121 logger.debug("Initialize web socket failed", e);
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() {
134 amazonEchoControlWebSocket.sendPing();
139 public boolean isClosed() {
143 public void close() {
145 Timer pingTimer = this.pingTimer;
146 if (pingTimer != null) {
149 clearPongTimeoutTimer();
150 Session session = this.session;
152 if (session != null) {
155 } catch (Exception e) {
156 logger.debug("Closing session failed", e);
159 logger.trace("Connect future = {}", sessionFuture);
160 final Future<?> sessionFuture = this.sessionFuture;
161 if (sessionFuture != null && !sessionFuture.isDone()) {
162 sessionFuture.cancel(true);
165 webSocketClient.stop();
166 } catch (InterruptedException e) {
168 } catch (Exception e) {
169 logger.debug("Stopping websocket failed", e);
171 webSocketClient.destroy();
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();
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() {
192 logger.trace("Pong timeout reached. Closing connection.");
198 @WebSocket(maxTextMessageSize = 64 * 1024, maxBinaryMessageSize = 64 * 1024)
199 @SuppressWarnings("unused")
200 public class AmazonEchoControlWebSocket {
204 AmazonEchoControlWebSocket() {
205 this.messageId = ThreadLocalRandom.current().nextInt(0, Short.MAX_VALUE);
208 void sendMessage(String message) {
209 sendMessage(message.getBytes(StandardCharsets.UTF_8));
212 void sendMessageHex(String message) {
213 sendMessage(hexStringToByteArray(message));
216 void sendMessage(byte[] buffer) {
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));
223 } catch (IOException e) {
224 logger.debug("Send message failed", e);
225 WebSocketConnection.this.close();
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);
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);
243 return Long.parseLong(str, 16);
246 String readString(byte[] data, int index, int length) {
247 return new String(data, index, length, StandardCharsets.UTF_8);
252 Content content = new Content();
253 String contentTune = "";
254 String messageType = "";
258 String moreFlag = "";
263 String messageType = "";
264 String protocolVersion = "";
265 String connectionUUID = "";
269 String subMessageType = "";
271 String destinationIdentityUrn = "";
272 String deviceIdentityUrn = "";
275 byte[] payloadData = new byte[0];
277 JsonPushCommand pushCommand;
280 Message parseIncomingMessage(byte[] data) {
282 Message message = new Message();
283 message.service = readString(data, data.length - 4, 4);
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);
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;
305 // currently not used: long contentLength = readHex(data, idx, 10);
306 idx += 11; // 10 + delimiter;
308 message.content.messageType = readString(data, idx, 3);
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);
317 length = (int) readHex(data, idx, 10);
318 idx += 11; // 10 + delimiter;
319 message.content.connectionUUID = readString(data, idx, length);
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;
328 } else if (message.channel == 0x362) { // GW_CHANNEL
329 if (message.content.messageType.equals("GWM")) {
330 message.content.subMessageType = readString(data, idx, 3);
332 message.content.channel = readHex(data, idx, 10);
333 idx += 11; // 10 + delimiter;
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);
341 length = (int) readHex(data, idx, 10);
342 idx += 11; // 10 + delimiter;
343 String idData = readString(data, idx, length);
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];
352 if (message.content.payload == null) {
353 payload = readString(data, idx, data.length - 4 - idx);
355 message.content.payload = payload;
356 if (StringUtils.isNotEmpty(payload)) {
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);
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);
376 public void onWebSocketConnect(@Nullable Session session) {
377 if (session != null) {
378 this.msgCounter = -1;
380 sendMessage("0x99d4f71a 0x0000001d A:HTUNE");
382 logger.debug("Web Socket connect without session");
387 public void onWebSocketBinary(byte @Nullable [] data, int offset, int len) {
392 if (this.msgCounter == 0) {
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());
400 byte[] buffer = data;
401 if (offset > 0 || len != buffer.length) {
402 buffer = Arrays.copyOfRange(data, offset, offset + len);
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();
412 JsonPushCommand pushCommand = message.content.pushCommand;
413 logger.debug("Message received: {}", message.content.payload);
414 if (pushCommand != null) {
415 webSocketCommandHandler.webSocketCommandReceived(pushCommand);
419 } catch (Exception e) {
420 logger.debug("Handling of push notification failed", e);
426 public void onWebSocketText(@Nullable String message) {
427 logger.trace("Received text message: '{}'", message);
431 public void onWebSocketClose(int code, @Nullable String reason) {
432 logger.info("Web Socket close {}. Reason: {}", code, reason);
433 WebSocketConnection.this.close();
437 public void onWebSocketError(@Nullable Throwable error) {
438 logger.info("Web Socket error", error);
440 WebSocketConnection.this.close();
444 public void sendPing() {
445 logger.debug("Send Ping");
446 WebSocketConnection.this.initPongTimeoutTimer();
447 sendMessage(encodePing());
450 String encodeNumber(long val) {
451 return encodeNumber(val, 8);
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);
459 while (str.length() < len) {
465 long computeBits(long input, long len) {
466 long lenCounter = len;
468 for (value = toUnsignedInt(input); 0 != lenCounter && 0 != value;) {
469 value = (long) Math.floor(value / 2);
475 long toUnsignedInt(long value) {
478 result = 4294967295L + value + 1;
483 int computeChecksum(byte[] data, int exclusionStart, int exclusionEnd) {
484 if (exclusionEnd < exclusionStart) {
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);
497 index = exclusionEnd - 1;
500 while (overflow != 0) {
502 overflow = computeBits(sum, 32);
503 sum = (int) sum & (int) 4294967295L;
505 long value = toUnsignedInt(sum);
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');
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();
522 msg += this.encodeNumber(new Date().getTime(), 16);
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);
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);
533 return completeBuffer;
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');
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";
550 byte[] completeBuffer = msg.getBytes(StandardCharsets.US_ASCII);
552 int checksum = this.computeChecksum(completeBuffer, checkSumStart, checkSumEnd);
554 String checksumHex = encodeNumber(checksum);
555 byte[] checksumBuf = checksumHex.getBytes(StandardCharsets.US_ASCII);
556 System.arraycopy(checksumBuf, 0, completeBuffer, checkSumStart, checksumBuf.length);
558 String test = readString(completeBuffer, 0, completeBuffer.length);
560 return completeBuffer;
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);
569 byte[] encodePing() {
570 // MSG 0x00000065 0x0e414e47 f 0x00000001 0xbc2fbb5f 0x00000062
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
579 byte[] completeBuffer = new byte[0x62];
580 byte[] startBuffer = msg.getBytes(StandardCharsets.US_ASCII);
582 System.arraycopy(startBuffer, 0, completeBuffer, 0, startBuffer.length);
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];
588 System.arraycopy(header, 0, bufferPing, 0, header.length);
589 idx += header.length;
590 encode(bufferPing, 0, idx, 4);
592 encode(bufferPing, new Date().getTime(), idx, 8);
594 encode(bufferPing, payload.length, idx, 4);
597 for (int q = 0; q < payload.length; q++) {
598 bufferPing[idx + q * 2] = (byte) 0;
599 bufferPing[idx + q * 2 + 1] = payload[q];
601 System.arraycopy(bufferPing, 0, completeBuffer, startBuffer.length, bufferPing.length);
603 byte[] buf2End = "FABE".getBytes(StandardCharsets.US_ASCII);
604 System.arraycopy(buf2End, 0, completeBuffer, startBuffer.length + bufferPing.length, buf2End.length);
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;