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.io.InputStream;
17 import java.io.InterruptedIOException;
18 import java.io.OutputStream;
19 import java.net.CookieManager;
20 import java.net.CookieStore;
21 import java.net.HttpCookie;
23 import java.net.URISyntaxException;
25 import java.net.URLDecoder;
26 import java.net.URLEncoder;
27 import java.nio.charset.StandardCharsets;
28 import java.text.SimpleDateFormat;
29 import java.util.ArrayList;
30 import java.util.Arrays;
31 import java.util.Base64;
32 import java.util.Collections;
33 import java.util.Date;
34 import java.util.HashMap;
35 import java.util.Iterator;
36 import java.util.LinkedHashMap;
37 import java.util.List;
39 import java.util.Objects;
40 import java.util.Random;
41 import java.util.Scanner;
43 import java.util.concurrent.*;
44 import java.util.concurrent.locks.Lock;
45 import java.util.concurrent.locks.ReentrantLock;
46 import java.util.regex.Matcher;
47 import java.util.regex.Pattern;
48 import java.util.zip.GZIPInputStream;
50 import javax.net.ssl.HttpsURLConnection;
52 import org.eclipse.jdt.annotation.NonNullByDefault;
53 import org.eclipse.jdt.annotation.Nullable;
54 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonActivities;
55 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonActivities.Activity;
56 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAnnouncementContent;
57 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAnnouncementTarget;
58 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAscendingAlarm;
59 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAscendingAlarm.AscendingAlarmModel;
60 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAutomation;
61 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAutomation.Payload;
62 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAutomation.Trigger;
63 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBluetoothStates;
64 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBootstrapResult;
65 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBootstrapResult.Authentication;
66 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDeviceNotificationState;
67 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDeviceNotificationState.DeviceNotificationState;
68 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDevices;
69 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDevices.Device;
70 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonEnabledFeeds;
71 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonEqualizer;
72 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonExchangeTokenResponse;
73 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonExchangeTokenResponse.Cookie;
74 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonFeed;
75 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonMediaState;
76 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonMusicProvider;
77 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNetworkDetails;
78 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationRequest;
79 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationResponse;
80 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationSound;
81 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationSounds;
82 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationsResponse;
83 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlaySearchPhraseOperationPayload;
84 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlayValidationResult;
85 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlayerState;
86 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlaylists;
87 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppRequest;
88 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse;
89 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Bearer;
90 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.DeviceInfo;
91 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Extensions;
92 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Response;
93 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Success;
94 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Tokens;
95 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRenewTokenResponse;
96 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonSmartHomeDevices.SmartHomeDevice;
97 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonSmartHomeGroups.SmartHomeGroup;
98 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonStartRoutineRequest;
99 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonUsersMeResponse;
100 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonWakeWords;
101 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonWakeWords.WakeWord;
102 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonWebSiteCookie;
103 import org.openhab.binding.amazonechocontrol.internal.jsons.SmartHomeBaseDevice;
104 import org.openhab.core.common.ThreadPoolManager;
105 import org.openhab.core.util.HexUtils;
106 import org.slf4j.Logger;
107 import org.slf4j.LoggerFactory;
109 import com.google.gson.*;
112 * The {@link Connection} is responsible for the connection to the amazon server
113 * and handling of the commands
115 * @author Michael Geramb - Initial contribution
118 public class Connection {
119 private static final String THING_THREADPOOL_NAME = "thingHandler";
120 private static final long EXPIRES_IN = 432000; // five days
121 private static final Pattern CHARSET_PATTERN = Pattern.compile("(?i)\\bcharset=\\s*\"?([^\\s;\"]*)");
122 private static final String DEVICE_TYPE = "A2IVLV5VM2W81";
124 private final Logger logger = LoggerFactory.getLogger(Connection.class);
126 protected final ScheduledExecutorService scheduler = ThreadPoolManager.getScheduledPool(THING_THREADPOOL_NAME);
128 private final Random rand = new Random();
129 private final CookieManager cookieManager = new CookieManager();
130 private final Gson gson;
131 private final Gson gsonWithNullSerialization;
133 private String amazonSite = "amazon.com";
134 private String alexaServer = "https://alexa.amazon.com";
135 private final String userAgent;
137 private String serial;
138 private String deviceId;
140 private @Nullable String refreshToken;
141 private @Nullable Date loginTime;
142 private @Nullable Date verifyTime;
143 private long renewTime = 0;
144 private @Nullable String deviceName;
145 private @Nullable String accountCustomerId;
146 private @Nullable String customerName;
148 private Map<Integer, AnnouncementWrapper> announcements = Collections.synchronizedMap(new LinkedHashMap<>());
149 private Map<Integer, TextToSpeech> textToSpeeches = Collections.synchronizedMap(new LinkedHashMap<>());
150 private Map<Integer, Volume> volumes = Collections.synchronizedMap(new LinkedHashMap<>());
151 private Map<String, LinkedBlockingQueue<QueueObject>> devices = Collections.synchronizedMap(new LinkedHashMap<>());
153 private final Map<TimerType, ScheduledFuture<?>> timers = new ConcurrentHashMap<>();
154 private final Map<TimerType, Lock> locks = new ConcurrentHashMap<>();
156 private enum TimerType {
163 public Connection(@Nullable Connection oldConnection, Gson gson) {
166 String serial = null;
167 String deviceId = null;
168 if (oldConnection != null) {
169 deviceId = oldConnection.getDeviceId();
170 frc = oldConnection.getFrc();
171 serial = oldConnection.getSerial();
177 byte[] frcBinary = new byte[313];
178 rand.nextBytes(frcBinary);
179 this.frc = Base64.getEncoder().encodeToString(frcBinary);
181 if (serial != null) {
182 this.serial = serial;
185 byte[] serialBinary = new byte[16];
186 rand.nextBytes(serialBinary);
187 this.serial = HexUtils.bytesToHex(serialBinary);
189 if (deviceId != null) {
190 this.deviceId = deviceId;
192 this.deviceId = generateDeviceId();
196 this.userAgent = "AmazonWebView/Amazon Alexa/2.2.223830.0/iOS/11.4.1/iPhone";
197 GsonBuilder gsonBuilder = new GsonBuilder();
198 gsonWithNullSerialization = gsonBuilder.create();
200 replaceTimer(TimerType.DEVICES,
201 scheduler.scheduleWithFixedDelay(this::handleExecuteSequenceNode, 0, 500, TimeUnit.MILLISECONDS));
205 * Generate a new device id
207 * The device id consists of 16 random bytes in upper-case hex format, a # as separator and a fixed DEVICE_TYPE
209 * @return a string containing the new device-id
211 private String generateDeviceId() {
212 byte[] bytes = new byte[16];
213 rand.nextBytes(bytes);
214 String hexStr = HexUtils.bytesToHex(bytes).toUpperCase() + "#" + DEVICE_TYPE;
215 return HexUtils.bytesToHex(hexStr.getBytes());
219 * Check if deviceId is valid (consisting of hex(hex(16 random bytes)) + "#" + DEVICE_TYPE)
221 * @param deviceId the deviceId
222 * @return true if valid, false if invalid
224 private boolean checkDeviceIdIsValid(@Nullable String deviceId) {
225 if (deviceId != null && deviceId.matches("^[0-9a-fA-F]{92}$")) {
226 String hexString = new String(HexUtils.hexToBytes(deviceId));
227 if (hexString.matches("^[0-9A-F]{32}#" + DEVICE_TYPE + "$")) {
234 private void setAmazonSite(@Nullable String amazonSite) {
235 String correctedAmazonSite = amazonSite != null ? amazonSite : "amazon.com";
236 if (correctedAmazonSite.toLowerCase().startsWith("http://")) {
237 correctedAmazonSite = correctedAmazonSite.substring(7);
239 if (correctedAmazonSite.toLowerCase().startsWith("https://")) {
240 correctedAmazonSite = correctedAmazonSite.substring(8);
242 if (correctedAmazonSite.toLowerCase().startsWith("www.")) {
243 correctedAmazonSite = correctedAmazonSite.substring(4);
245 if (correctedAmazonSite.toLowerCase().startsWith("alexa.")) {
246 correctedAmazonSite = correctedAmazonSite.substring(6);
248 this.amazonSite = correctedAmazonSite;
249 alexaServer = "https://alexa." + this.amazonSite;
252 public @Nullable Date tryGetLoginTime() {
256 public @Nullable Date tryGetVerifyTime() {
260 public String getFrc() {
264 public String getSerial() {
268 public String getDeviceId() {
272 public String getAmazonSite() {
276 public String getAlexaServer() {
280 public String getDeviceName() {
281 String deviceName = this.deviceName;
282 if (deviceName == null) {
288 public String getCustomerId() {
289 String customerId = this.accountCustomerId;
290 if (customerId == null) {
296 public String getCustomerName() {
297 String customerName = this.customerName;
298 if (customerName == null) {
304 public boolean isSequenceNodeQueueRunning() {
305 return devices.values().stream().anyMatch(
306 (queueObjects) -> (queueObjects.stream().anyMatch(queueObject -> queueObject.future != null)));
309 public String serializeLoginData() {
310 Date loginTime = this.loginTime;
311 if (refreshToken == null || loginTime == null) {
314 StringBuilder builder = new StringBuilder();
315 builder.append("7\n"); // version
317 builder.append("\n");
318 builder.append(serial);
319 builder.append("\n");
320 builder.append(deviceId);
321 builder.append("\n");
322 builder.append(refreshToken);
323 builder.append("\n");
324 builder.append(amazonSite);
325 builder.append("\n");
326 builder.append(deviceName);
327 builder.append("\n");
328 builder.append(accountCustomerId);
329 builder.append("\n");
330 builder.append(loginTime.getTime());
331 builder.append("\n");
332 List<HttpCookie> cookies = cookieManager.getCookieStore().getCookies();
333 builder.append(cookies.size());
334 builder.append("\n");
335 for (HttpCookie cookie : cookies) {
336 writeValue(builder, cookie.getName());
337 writeValue(builder, cookie.getValue());
338 writeValue(builder, cookie.getComment());
339 writeValue(builder, cookie.getCommentURL());
340 writeValue(builder, cookie.getDomain());
341 writeValue(builder, cookie.getMaxAge());
342 writeValue(builder, cookie.getPath());
343 writeValue(builder, cookie.getPortlist());
344 writeValue(builder, cookie.getVersion());
345 writeValue(builder, cookie.getSecure());
346 writeValue(builder, cookie.getDiscard());
348 return builder.toString();
351 private void writeValue(StringBuilder builder, @Nullable Object value) {
356 builder.append("\n");
357 builder.append(value.toString());
359 builder.append("\n");
362 private String readValue(Scanner scanner) {
363 if (scanner.nextLine().equals("1")) {
364 String result = scanner.nextLine();
365 if (result != null) {
372 public boolean tryRestoreLogin(@Nullable String data, @Nullable String overloadedDomain) {
373 Date loginTime = tryRestoreSessionData(data, overloadedDomain);
374 if (loginTime != null) {
377 this.loginTime = loginTime;
380 } catch (IOException e) {
382 } catch (URISyntaxException | InterruptedException e) {
388 private @Nullable Date tryRestoreSessionData(@Nullable String data, @Nullable String overloadedDomain) {
390 if (data == null || data.isEmpty()) {
393 Scanner scanner = new Scanner(data);
394 String version = scanner.nextLine();
395 // check if serialize version is supported
396 if (!"5".equals(version) && !"6".equals(version) && !"7".equals(version)) {
400 int intVersion = Integer.parseInt(version);
402 frc = scanner.nextLine();
403 serial = scanner.nextLine();
404 deviceId = scanner.nextLine();
406 // Recreate session and cookies
407 refreshToken = scanner.nextLine();
408 String domain = scanner.nextLine();
409 if (overloadedDomain != null) {
410 domain = overloadedDomain;
412 setAmazonSite(domain);
414 deviceName = scanner.nextLine();
416 if (intVersion > 5) {
417 String accountCustomerId = scanner.nextLine();
418 // Note: version 5 have wrong customer id serialized.
419 // Only use it, if it at least version 6 of serialization
420 if (intVersion > 6) {
421 if (!"null".equals(accountCustomerId)) {
422 this.accountCustomerId = accountCustomerId;
427 Date loginTime = new Date(Long.parseLong(scanner.nextLine()));
428 CookieStore cookieStore = cookieManager.getCookieStore();
429 cookieStore.removeAll();
431 Integer numberOfCookies = Integer.parseInt(scanner.nextLine());
432 for (Integer i = 0; i < numberOfCookies; i++) {
433 String name = readValue(scanner);
434 String value = readValue(scanner);
436 HttpCookie clientCookie = new HttpCookie(name, value);
437 clientCookie.setComment(readValue(scanner));
438 clientCookie.setCommentURL(readValue(scanner));
439 clientCookie.setDomain(readValue(scanner));
440 clientCookie.setMaxAge(Long.parseLong(readValue(scanner)));
441 clientCookie.setPath(readValue(scanner));
442 clientCookie.setPortlist(readValue(scanner));
443 clientCookie.setVersion(Integer.parseInt(readValue(scanner)));
444 clientCookie.setSecure(Boolean.parseBoolean(readValue(scanner)));
445 clientCookie.setDiscard(Boolean.parseBoolean(readValue(scanner)));
447 cookieStore.add(null, clientCookie);
453 String accountCustomerId = this.accountCustomerId;
454 if (accountCustomerId == null || accountCustomerId.isEmpty()) {
455 List<Device> devices = this.getDeviceList();
456 accountCustomerId = devices.stream().filter(device -> serial.equals(device.serialNumber)).findAny()
457 .map(device -> device.deviceOwnerCustomerId).orElse(null);
458 if (accountCustomerId == null || accountCustomerId.isEmpty()) {
459 accountCustomerId = devices.stream().filter(device -> "This Device".equals(device.accountName))
460 .findAny().map(device -> {
461 serial = Objects.requireNonNullElse(device.serialNumber, serial);
462 return device.deviceOwnerCustomerId;
465 this.accountCustomerId = accountCustomerId;
467 } catch (URISyntaxException | IOException | InterruptedException | ConnectionException e) {
468 logger.debug("Getting account customer Id failed", e);
473 private @Nullable Authentication tryGetBootstrap() throws IOException, URISyntaxException, InterruptedException {
474 HttpsURLConnection connection = makeRequest("GET", alexaServer + "/api/bootstrap", null, false, false, null, 0);
475 String contentType = connection.getContentType();
476 if (connection.getResponseCode() == 200 && contentType != null
477 && contentType.toLowerCase().startsWith("application/json")) {
479 String bootstrapResultJson = convertStream(connection);
480 JsonBootstrapResult result = parseJson(bootstrapResultJson, JsonBootstrapResult.class);
481 if (result != null) {
482 Authentication authentication = result.authentication;
483 if (authentication != null && authentication.authenticated) {
484 this.customerName = authentication.customerName;
485 if (this.accountCustomerId == null) {
486 this.accountCustomerId = authentication.customerId;
488 return authentication;
491 } catch (JsonSyntaxException | IllegalStateException e) {
492 logger.info("No valid json received", e);
499 public String convertStream(HttpsURLConnection connection) throws IOException {
500 InputStream input = connection.getInputStream();
505 InputStream readerStream;
506 if ("gzip".equalsIgnoreCase(connection.getContentEncoding())) {
507 readerStream = new GZIPInputStream(connection.getInputStream());
509 readerStream = input;
511 String contentType = connection.getContentType();
512 String charSet = null;
513 if (contentType != null) {
514 Matcher m = CHARSET_PATTERN.matcher(contentType);
516 charSet = m.group(1).trim().toUpperCase();
520 Scanner inputScanner = charSet == null || charSet.isEmpty()
521 ? new Scanner(readerStream, StandardCharsets.UTF_8.name())
522 : new Scanner(readerStream, charSet);
523 Scanner scannerWithoutDelimiter = inputScanner.useDelimiter("\\A");
524 String result = scannerWithoutDelimiter.hasNext() ? scannerWithoutDelimiter.next() : null;
525 inputScanner.close();
526 scannerWithoutDelimiter.close();
528 if (result == null) {
534 public String makeRequestAndReturnString(String url) throws IOException, URISyntaxException, InterruptedException {
535 return makeRequestAndReturnString("GET", url, null, false, null);
538 public String makeRequestAndReturnString(String verb, String url, @Nullable String postData, boolean json,
539 @Nullable Map<String, String> customHeaders) throws IOException, URISyntaxException, InterruptedException {
540 HttpsURLConnection connection = makeRequest(verb, url, postData, json, true, customHeaders, 3);
541 String result = convertStream(connection);
542 logger.debug("Result of {} {}:{}", verb, url, result);
546 public HttpsURLConnection makeRequest(String verb, String url, @Nullable String postData, boolean json,
547 boolean autoredirect, @Nullable Map<String, String> customHeaders, int badRequestRepeats)
548 throws IOException, URISyntaxException, InterruptedException {
549 String currentUrl = url;
550 int redirectCounter = 0;
551 int retryCounter = 0;
552 // loop for handling redirect and bad request, using automatic redirect is not
553 // possible, because all response headers must be catched
556 HttpsURLConnection connection = null;
558 logger.debug("Make request to {}", url);
559 connection = (HttpsURLConnection) new URL(currentUrl).openConnection();
560 connection.setRequestMethod(verb);
561 connection.setRequestProperty("Accept-Language", "en-US");
562 if (customHeaders == null || !customHeaders.containsKey("User-Agent")) {
563 connection.setRequestProperty("User-Agent", userAgent);
565 connection.setRequestProperty("Accept-Encoding", "gzip");
566 connection.setRequestProperty("DNT", "1");
567 connection.setRequestProperty("Upgrade-Insecure-Requests", "1");
568 if (customHeaders != null) {
569 for (String key : customHeaders.keySet()) {
570 String value = customHeaders.get(key);
571 if (value != null && !value.isEmpty()) {
572 connection.setRequestProperty(key, value);
576 connection.setInstanceFollowRedirects(false);
579 URI uri = connection.getURL().toURI();
581 if (customHeaders == null || !customHeaders.containsKey("Cookie")) {
582 StringBuilder cookieHeaderBuilder = new StringBuilder();
583 for (HttpCookie cookie : cookieManager.getCookieStore().get(uri)) {
584 if (cookieHeaderBuilder.length() > 0) {
585 cookieHeaderBuilder.append(";");
587 cookieHeaderBuilder.append(cookie.getName());
588 cookieHeaderBuilder.append("=");
589 cookieHeaderBuilder.append(cookie.getValue());
590 if (cookie.getName().equals("csrf")) {
591 connection.setRequestProperty("csrf", cookie.getValue());
595 if (cookieHeaderBuilder.length() > 0) {
596 String cookies = cookieHeaderBuilder.toString();
597 connection.setRequestProperty("Cookie", cookies);
600 if (postData != null) {
601 logger.debug("{}: {}", verb, postData);
603 byte[] postDataBytes = postData.getBytes(StandardCharsets.UTF_8);
604 int postDataLength = postDataBytes.length;
606 connection.setFixedLengthStreamingMode(postDataLength);
609 connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
611 connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
613 connection.setRequestProperty("Content-Length", Integer.toString(postDataLength));
614 if ("POST".equals(verb)) {
615 connection.setRequestProperty("Expect", "100-continue");
618 connection.setDoOutput(true);
619 OutputStream outputStream = connection.getOutputStream();
620 outputStream.write(postDataBytes);
621 outputStream.close();
624 code = connection.getResponseCode();
625 String location = null;
627 // handle response headers
628 Map<@Nullable String, List<String>> headerFields = connection.getHeaderFields();
629 for (Map.Entry<@Nullable String, List<String>> header : headerFields.entrySet()) {
630 String key = header.getKey();
631 if (key != null && !key.isEmpty()) {
632 if (key.equalsIgnoreCase("Set-Cookie")) {
634 for (String cookieHeader : header.getValue()) {
635 if (!cookieHeader.isEmpty()) {
636 List<HttpCookie> cookies = HttpCookie.parse(cookieHeader);
637 for (HttpCookie cookie : cookies) {
638 cookieManager.getCookieStore().add(uri, cookie);
643 if (key.equalsIgnoreCase("Location")) {
644 // get redirect location
645 location = header.getValue().get(0);
646 if (!location.isEmpty()) {
647 location = uri.resolve(location).toString();
649 if (location.toLowerCase().startsWith("http://")) {
651 location = "https://" + location.substring(7);
652 logger.debug("Redirect corrected to {}", location);
659 logger.debug("Call to {} succeeded", url);
661 } else if (code == 302 && location != null) {
662 logger.debug("Redirected to {}", location);
664 if (redirectCounter > 30) {
665 throw new ConnectionException("Too many redirects");
667 currentUrl = location;
669 continue; // repeat with new location
673 logger.debug("Retry call to {}", url);
675 if (retryCounter > badRequestRepeats) {
676 throw new HttpException(code,
677 verb + " url '" + url + "' failed: " + connection.getResponseMessage());
681 } catch (InterruptedException | InterruptedIOException e) {
682 if (connection != null) {
683 connection.disconnect();
685 logger.warn("Unable to wait for next call to {}", url, e);
687 } catch (IOException e) {
688 if (connection != null) {
689 connection.disconnect();
691 logger.warn("Request to url '{}' fails with unknown error", url, e);
693 } catch (Exception e) {
694 if (connection != null) {
695 connection.disconnect();
702 public String registerConnectionAsApp(String oAutRedirectUrl)
703 throws ConnectionException, IOException, URISyntaxException, InterruptedException {
704 URI oAutRedirectUri = new URI(oAutRedirectUrl);
706 Map<String, String> queryParameters = new LinkedHashMap<>();
707 String query = oAutRedirectUri.getQuery();
708 String[] pairs = query.split("&");
709 for (String pair : pairs) {
710 int idx = pair.indexOf("=");
711 queryParameters.put(URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8.name()),
712 URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8.name()));
714 String accessToken = queryParameters.get("openid.oa2.access_token");
716 Map<String, String> cookieMap = new HashMap<>();
718 List<JsonWebSiteCookie> webSiteCookies = new ArrayList<>();
719 for (HttpCookie cookie : getSessionCookies("https://www.amazon.com")) {
720 cookieMap.put(cookie.getName(), cookie.getValue());
721 webSiteCookies.add(new JsonWebSiteCookie(cookie.getName(), cookie.getValue()));
724 JsonWebSiteCookie[] webSiteCookiesArray = new JsonWebSiteCookie[webSiteCookies.size()];
725 webSiteCookiesArray = webSiteCookies.toArray(webSiteCookiesArray);
727 JsonRegisterAppRequest registerAppRequest = new JsonRegisterAppRequest(serial, accessToken, frc,
728 webSiteCookiesArray);
729 String registerAppRequestJson = gson.toJson(registerAppRequest);
731 HashMap<String, String> registerHeaders = new HashMap<>();
732 registerHeaders.put("x-amzn-identity-auth-domain", "api.amazon.com");
734 String registerAppResultJson = makeRequestAndReturnString("POST", "https://api.amazon.com/auth/register",
735 registerAppRequestJson, true, registerHeaders);
736 JsonRegisterAppResponse registerAppResponse = parseJson(registerAppResultJson, JsonRegisterAppResponse.class);
738 if (registerAppResponse == null) {
739 throw new ConnectionException("Error: No response received from register application");
741 Response response = registerAppResponse.response;
742 if (response == null) {
743 throw new ConnectionException("Error: No response received from register application");
745 Success success = response.success;
746 if (success == null) {
747 throw new ConnectionException("Error: No success received from register application");
749 Tokens tokens = success.tokens;
750 if (tokens == null) {
751 throw new ConnectionException("Error: No tokens received from register application");
753 Bearer bearer = tokens.bearer;
754 if (bearer == null) {
755 throw new ConnectionException("Error: No bearer received from register application");
757 String refreshToken = bearer.refreshToken;
758 this.refreshToken = refreshToken;
759 if (refreshToken == null || refreshToken.isEmpty()) {
760 throw new ConnectionException("Error: No refresh token received");
764 // Check which is the owner domain
765 String usersMeResponseJson = makeRequestAndReturnString("GET",
766 "https://alexa.amazon.com/api/users/me?platform=ios&version=2.2.223830.0", null, false, null);
767 JsonUsersMeResponse usersMeResponse = parseJson(usersMeResponseJson, JsonUsersMeResponse.class);
768 if (usersMeResponse == null) {
769 throw new IllegalArgumentException("Received no response on me-request");
771 URI uri = new URI(usersMeResponse.marketPlaceDomainName);
772 String host = uri.getHost();
774 // Switch to owner domain
778 } catch (Exception e) {
782 String deviceName = null;
783 Extensions extensions = success.extensions;
784 if (extensions != null) {
785 DeviceInfo deviceInfo = extensions.deviceInfo;
786 if (deviceInfo != null) {
787 deviceName = deviceInfo.deviceName;
790 if (deviceName == null) {
791 deviceName = "Unknown";
793 this.deviceName = deviceName;
797 private void exchangeToken() throws IOException, URISyntaxException, InterruptedException {
799 String cookiesJson = "{\"cookies\":{\"." + getAmazonSite() + "\":[]}}";
800 String cookiesBase64 = Base64.getEncoder().encodeToString(cookiesJson.getBytes());
802 String exchangePostData = "di.os.name=iOS&app_version=2.2.223830.0&domain=." + getAmazonSite()
803 + "&source_token=" + URLEncoder.encode(this.refreshToken, "UTF8")
804 + "&requested_token_type=auth_cookies&source_token_type=refresh_token&di.hw.version=iPhone&di.sdk.version=6.10.0&cookies="
805 + cookiesBase64 + "&app_name=Amazon%20Alexa&di.os.version=11.4.1";
807 HashMap<String, String> exchangeTokenHeader = new HashMap<>();
808 exchangeTokenHeader.put("Cookie", "");
810 String exchangeTokenJson = makeRequestAndReturnString("POST",
811 "https://www." + getAmazonSite() + "/ap/exchangetoken", exchangePostData, false, exchangeTokenHeader);
812 JsonExchangeTokenResponse exchangeTokenResponse = Objects
813 .requireNonNull(gson.fromJson(exchangeTokenJson, JsonExchangeTokenResponse.class));
815 org.openhab.binding.amazonechocontrol.internal.jsons.JsonExchangeTokenResponse.Response response = exchangeTokenResponse.response;
816 if (response != null) {
817 org.openhab.binding.amazonechocontrol.internal.jsons.JsonExchangeTokenResponse.Tokens tokens = response.tokens;
818 if (tokens != null) {
819 Map<String, Cookie[]> cookiesMap = tokens.cookies;
820 if (cookiesMap != null) {
821 for (String domain : cookiesMap.keySet()) {
822 Cookie[] cookies = cookiesMap.get(domain);
823 if (cookies != null) {
824 for (Cookie cookie : cookies) {
825 if (cookie != null) {
826 HttpCookie httpCookie = new HttpCookie(cookie.name, cookie.value);
827 httpCookie.setPath(cookie.path);
828 httpCookie.setDomain(domain);
829 Boolean secure = cookie.secure;
830 if (secure != null) {
831 httpCookie.setSecure(secure);
833 this.cookieManager.getCookieStore().add(null, httpCookie);
841 if (!verifyLogin()) {
842 throw new ConnectionException("Verify login failed after token exchange");
844 this.renewTime = (long) (System.currentTimeMillis() + Connection.EXPIRES_IN * 1000d / 0.8d); // start renew at
847 public boolean checkRenewSession() throws URISyntaxException, IOException, InterruptedException {
848 if (System.currentTimeMillis() >= this.renewTime) {
849 String renewTokenPostData = "app_name=Amazon%20Alexa&app_version=2.2.223830.0&di.sdk.version=6.10.0&source_token="
850 + URLEncoder.encode(refreshToken, StandardCharsets.UTF_8.name())
851 + "&package_name=com.amazon.echo&di.hw.version=iPhone&platform=iOS&requested_token_type=access_token&source_token_type=refresh_token&di.os.name=iOS&di.os.version=11.4.1¤t_version=6.10.0";
852 String renewTokenResponseJson = makeRequestAndReturnString("POST", "https://api.amazon.com/auth/token",
853 renewTokenPostData, false, null);
854 parseJson(renewTokenResponseJson, JsonRenewTokenResponse.class);
862 public boolean getIsLoggedIn() {
863 return loginTime != null;
866 public String getLoginPage() throws IOException, URISyntaxException, InterruptedException {
867 // clear session data
870 logger.debug("Start Login to {}", alexaServer);
872 if (!checkDeviceIdIsValid(deviceId)) {
873 deviceId = generateDeviceId();
874 logger.debug("Generating new device id (old device id had invalid format).");
877 String mapMdJson = "{\"device_user_dictionary\":[],\"device_registration_data\":{\"software_version\":\"1\"},\"app_identifier\":{\"app_version\":\"2.2.223830\",\"bundle_id\":\"com.amazon.echo\"}}";
878 String mapMdCookie = Base64.getEncoder().encodeToString(mapMdJson.getBytes());
880 cookieManager.getCookieStore().add(new URI("https://www.amazon.com"), new HttpCookie("map-md", mapMdCookie));
881 cookieManager.getCookieStore().add(new URI("https://www.amazon.com"), new HttpCookie("frc", frc));
883 Map<String, String> customHeaders = new HashMap<>();
884 customHeaders.put("authority", "www.amazon.com");
885 String loginFormHtml = makeRequestAndReturnString("GET", "https://www.amazon.com"
886 + "/ap/signin?openid.return_to=https://www.amazon.com/ap/maplanding&openid.assoc_handle=amzn_dp_project_dee_ios&openid.identity=http://specs.openid.net/auth/2.0/identifier_select&pageId=amzn_dp_project_dee_ios&accountStatusPolicy=P1&openid.claimed_id=http://specs.openid.net/auth/2.0/identifier_select&openid.mode=checkid_setup&openid.ns.oa2=http://www.amazon.com/ap/ext/oauth/2&openid.oa2.client_id=device:"
888 + "&openid.ns.pape=http://specs.openid.net/extensions/pape/1.0&openid.oa2.response_type=token&openid.ns=http://specs.openid.net/auth/2.0&openid.pape.max_auth_age=0&openid.oa2.scope=device_auth_access",
889 null, false, customHeaders);
891 logger.debug("Received login form {}", loginFormHtml);
892 return loginFormHtml;
895 public boolean verifyLogin() throws IOException, URISyntaxException, InterruptedException {
896 if (this.refreshToken == null) {
899 Authentication authentication = tryGetBootstrap();
900 if (authentication != null && authentication.authenticated) {
901 verifyTime = new Date();
902 if (loginTime == null) {
903 loginTime = verifyTime;
910 public List<HttpCookie> getSessionCookies() {
912 return cookieManager.getCookieStore().get(new URI(alexaServer));
913 } catch (URISyntaxException e) {
914 return new ArrayList<>();
918 public List<HttpCookie> getSessionCookies(String server) {
920 return cookieManager.getCookieStore().get(new URI(server));
921 } catch (URISyntaxException e) {
922 return new ArrayList<>();
926 private void replaceTimer(TimerType type, @Nullable ScheduledFuture<?> newTimer) {
927 timers.compute(type, (timerType, oldTimer) -> {
928 if (oldTimer != null) {
929 oldTimer.cancel(true);
935 public void logout() {
936 cookieManager.getCookieStore().removeAll();
943 replaceTimer(TimerType.ANNOUNCEMENT, null);
944 announcements.clear();
945 replaceTimer(TimerType.TTS, null);
946 textToSpeeches.clear();
947 replaceTimer(TimerType.VOLUME, null);
949 replaceTimer(TimerType.DEVICES, null);
951 devices.values().forEach((queueObjects) -> {
952 queueObjects.forEach((queueObject) -> {
953 Future<?> future = queueObject.future;
954 if (future != null) {
956 queueObject.future = null;
963 private <T> @Nullable T parseJson(String json, Class<T> type) throws JsonSyntaxException, IllegalStateException {
965 return gson.fromJson(json, type);
966 } catch (JsonParseException | IllegalStateException e) {
967 logger.warn("Parsing json failed: {}", json, e);
972 // commands and states
973 public WakeWord[] getWakeWords() {
976 json = makeRequestAndReturnString(alexaServer + "/api/wake-word?cached=true");
977 JsonWakeWords wakeWords = parseJson(json, JsonWakeWords.class);
978 if (wakeWords != null) {
979 WakeWord[] result = wakeWords.wakeWords;
980 if (result != null) {
984 } catch (IOException | URISyntaxException | InterruptedException e) {
985 logger.info("getting wakewords failed", e);
987 return new WakeWord[0];
990 public List<SmartHomeBaseDevice> getSmarthomeDeviceList()
991 throws IOException, URISyntaxException, InterruptedException {
993 String json = makeRequestAndReturnString(alexaServer + "/api/phoenix");
994 logger.debug("getSmartHomeDevices result: {}", json);
996 JsonNetworkDetails networkDetails = parseJson(json, JsonNetworkDetails.class);
997 if (networkDetails == null) {
998 throw new IllegalArgumentException("received no response on network detail request");
1000 Object jsonObject = gson.fromJson(networkDetails.networkDetail, Object.class);
1001 List<SmartHomeBaseDevice> result = new ArrayList<>();
1002 searchSmartHomeDevicesRecursive(jsonObject, result);
1005 } catch (Exception e) {
1006 logger.warn("getSmartHomeDevices fails: {}", e.getMessage());
1011 private void searchSmartHomeDevicesRecursive(@Nullable Object jsonNode, List<SmartHomeBaseDevice> devices) {
1012 if (jsonNode instanceof Map) {
1013 @SuppressWarnings("rawtypes")
1014 Map<String, Object> map = (Map) jsonNode;
1015 if (map.containsKey("entityId") && map.containsKey("friendlyName") && map.containsKey("actions")) {
1016 // device node found, create type element and add it to the results
1017 JsonElement element = gson.toJsonTree(jsonNode);
1018 SmartHomeDevice shd = parseJson(element.toString(), SmartHomeDevice.class);
1022 } else if (map.containsKey("applianceGroupName")) {
1023 JsonElement element = gson.toJsonTree(jsonNode);
1024 SmartHomeGroup shg = parseJson(element.toString(), SmartHomeGroup.class);
1029 map.values().forEach(value -> searchSmartHomeDevicesRecursive(value, devices));
1034 public List<Device> getDeviceList() throws IOException, URISyntaxException, InterruptedException {
1035 String json = getDeviceListJson();
1036 JsonDevices devices = parseJson(json, JsonDevices.class);
1037 if (devices != null) {
1038 Device[] result = devices.devices;
1039 if (result != null) {
1040 return new ArrayList<>(Arrays.asList(result));
1043 return Collections.emptyList();
1046 public String getDeviceListJson() throws IOException, URISyntaxException, InterruptedException {
1047 String json = makeRequestAndReturnString(alexaServer + "/api/devices-v2/device?cached=false");
1051 public Map<String, JsonArray> getSmartHomeDeviceStatesJson(Set<String> applianceIds)
1052 throws IOException, URISyntaxException, InterruptedException {
1053 JsonObject requestObject = new JsonObject();
1054 JsonArray stateRequests = new JsonArray();
1055 for (String applianceId : applianceIds) {
1056 JsonObject stateRequest = new JsonObject();
1057 stateRequest.addProperty("entityId", applianceId);
1058 stateRequest.addProperty("entityType", "APPLIANCE");
1059 stateRequests.add(stateRequest);
1061 requestObject.add("stateRequests", stateRequests);
1062 String requestBody = requestObject.toString();
1063 String json = makeRequestAndReturnString("POST", alexaServer + "/api/phoenix/state", requestBody, true, null);
1064 logger.trace("Requested {} and received {}", requestBody, json);
1066 JsonObject responseObject = Objects.requireNonNull(gson.fromJson(json, JsonObject.class));
1067 JsonArray deviceStates = (JsonArray) responseObject.get("deviceStates");
1068 Map<String, JsonArray> result = new HashMap<>();
1069 for (JsonElement deviceState : deviceStates) {
1070 JsonObject deviceStateObject = deviceState.getAsJsonObject();
1071 JsonObject entity = deviceStateObject.get("entity").getAsJsonObject();
1072 String applicanceId = entity.get("entityId").getAsString();
1073 JsonElement capabilityState = deviceStateObject.get("capabilityStates");
1074 if (capabilityState != null && capabilityState.isJsonArray()) {
1075 result.put(applicanceId, capabilityState.getAsJsonArray());
1081 public @Nullable JsonPlayerState getPlayer(Device device)
1082 throws IOException, URISyntaxException, InterruptedException {
1083 String json = makeRequestAndReturnString(alexaServer + "/api/np/player?deviceSerialNumber="
1084 + device.serialNumber + "&deviceType=" + device.deviceType + "&screenWidth=1440");
1085 JsonPlayerState playerState = parseJson(json, JsonPlayerState.class);
1089 public @Nullable JsonMediaState getMediaState(Device device)
1090 throws IOException, URISyntaxException, InterruptedException {
1091 String json = makeRequestAndReturnString(alexaServer + "/api/media/state?deviceSerialNumber="
1092 + device.serialNumber + "&deviceType=" + device.deviceType);
1093 JsonMediaState mediaState = parseJson(json, JsonMediaState.class);
1097 public Activity[] getActivities(int number, @Nullable Long startTime) {
1100 json = makeRequestAndReturnString(alexaServer + "/api/activities?startTime="
1101 + (startTime != null ? startTime : "") + "&size=" + number + "&offset=1");
1102 JsonActivities activities = parseJson(json, JsonActivities.class);
1103 if (activities != null) {
1104 Activity[] activiesArray = activities.activities;
1105 if (activiesArray != null) {
1106 return activiesArray;
1109 } catch (IOException | URISyntaxException | InterruptedException e) {
1110 logger.info("getting activities failed", e);
1112 return new Activity[0];
1115 public @Nullable JsonBluetoothStates getBluetoothConnectionStates() {
1118 json = makeRequestAndReturnString(alexaServer + "/api/bluetooth?cached=true");
1119 } catch (IOException | URISyntaxException | InterruptedException e) {
1120 logger.debug("failed to get bluetooth state: {}", e.getMessage());
1121 return new JsonBluetoothStates();
1123 JsonBluetoothStates bluetoothStates = parseJson(json, JsonBluetoothStates.class);
1124 return bluetoothStates;
1127 public @Nullable JsonPlaylists getPlaylists(Device device)
1128 throws IOException, URISyntaxException, InterruptedException {
1129 String json = makeRequestAndReturnString(
1130 alexaServer + "/api/cloudplayer/playlists?deviceSerialNumber=" + device.serialNumber + "&deviceType="
1131 + device.deviceType + "&mediaOwnerCustomerId=" + getCustomerId(device.deviceOwnerCustomerId));
1132 JsonPlaylists playlists = parseJson(json, JsonPlaylists.class);
1136 public void command(Device device, String command) throws IOException, URISyntaxException, InterruptedException {
1137 String url = alexaServer + "/api/np/command?deviceSerialNumber=" + device.serialNumber + "&deviceType="
1138 + device.deviceType;
1139 makeRequest("POST", url, command, true, true, null, 0);
1142 public void smartHomeCommand(String entityId, String action) throws IOException, InterruptedException {
1143 smartHomeCommand(entityId, action, null, null);
1146 public void smartHomeCommand(String entityId, String action, @Nullable String property, @Nullable Object value)
1147 throws IOException, InterruptedException {
1148 String url = alexaServer + "/api/phoenix/state";
1150 JsonObject json = new JsonObject();
1151 JsonArray controlRequests = new JsonArray();
1152 JsonObject controlRequest = new JsonObject();
1153 controlRequest.addProperty("entityId", entityId);
1154 controlRequest.addProperty("entityType", "APPLIANCE");
1155 JsonObject parameters = new JsonObject();
1156 parameters.addProperty("action", action);
1157 if (property != null) {
1158 if (value instanceof Boolean) {
1159 parameters.addProperty(property, (boolean) value);
1160 } else if (value instanceof String) {
1161 parameters.addProperty(property, (String) value);
1162 } else if (value instanceof Number) {
1163 parameters.addProperty(property, (Number) value);
1164 } else if (value instanceof Character) {
1165 parameters.addProperty(property, (Character) value);
1166 } else if (value instanceof JsonElement) {
1167 parameters.add(property, (JsonElement) value);
1170 controlRequest.add("parameters", parameters);
1171 controlRequests.add(controlRequest);
1172 json.add("controlRequests", controlRequests);
1174 String requestBody = json.toString();
1176 String resultBody = makeRequestAndReturnString("PUT", url, requestBody, true, null);
1177 logger.debug("{}", resultBody);
1178 JsonObject result = parseJson(resultBody, JsonObject.class);
1179 if (result != null) {
1180 JsonElement errors = result.get("errors");
1181 if (errors != null && errors.isJsonArray()) {
1182 JsonArray errorList = errors.getAsJsonArray();
1183 if (errorList.size() > 0) {
1184 logger.info("Smart home device command failed.");
1185 logger.info("Request:");
1186 logger.info("{}", requestBody);
1187 logger.info("Answer:");
1188 for (JsonElement error : errorList) {
1189 logger.info("{}", error.toString());
1194 } catch (URISyntaxException e) {
1195 logger.info("Wrong url {}", url, e);
1199 public void notificationVolume(Device device, int volume)
1200 throws IOException, URISyntaxException, InterruptedException {
1201 String url = alexaServer + "/api/device-notification-state/" + device.deviceType + "/" + device.softwareVersion
1202 + "/" + device.serialNumber;
1203 String command = "{\"deviceSerialNumber\":\"" + device.serialNumber + "\",\"deviceType\":\"" + device.deviceType
1204 + "\",\"softwareVersion\":\"" + device.softwareVersion + "\",\"volumeLevel\":" + volume + "}";
1205 makeRequest("PUT", url, command, true, true, null, 0);
1208 public void ascendingAlarm(Device device, boolean ascendingAlarm)
1209 throws IOException, URISyntaxException, InterruptedException {
1210 String url = alexaServer + "/api/ascending-alarm/" + device.serialNumber;
1211 String command = "{\"ascendingAlarmEnabled\":" + (ascendingAlarm ? "true" : "false")
1212 + ",\"deviceSerialNumber\":\"" + device.serialNumber + "\",\"deviceType\":\"" + device.deviceType
1213 + "\",\"deviceAccountId\":null}";
1214 makeRequest("PUT", url, command, true, true, null, 0);
1217 public DeviceNotificationState[] getDeviceNotificationStates() {
1220 json = makeRequestAndReturnString(alexaServer + "/api/device-notification-state");
1221 JsonDeviceNotificationState result = parseJson(json, JsonDeviceNotificationState.class);
1222 if (result != null) {
1223 DeviceNotificationState[] deviceNotificationStates = result.deviceNotificationStates;
1224 if (deviceNotificationStates != null) {
1225 return deviceNotificationStates;
1228 } catch (IOException | URISyntaxException | InterruptedException e) {
1229 logger.info("Error getting device notification states", e);
1231 return new DeviceNotificationState[0];
1234 public AscendingAlarmModel[] getAscendingAlarm() {
1237 json = makeRequestAndReturnString(alexaServer + "/api/ascending-alarm");
1238 JsonAscendingAlarm result = parseJson(json, JsonAscendingAlarm.class);
1239 if (result != null) {
1240 AscendingAlarmModel[] ascendingAlarmModelList = result.ascendingAlarmModelList;
1241 if (ascendingAlarmModelList != null) {
1242 return ascendingAlarmModelList;
1245 } catch (IOException | URISyntaxException | InterruptedException e) {
1246 logger.info("Error getting device notification states", e);
1248 return new AscendingAlarmModel[0];
1251 public void bluetooth(Device device, @Nullable String address)
1252 throws IOException, URISyntaxException, InterruptedException {
1253 if (address == null || address.isEmpty()) {
1256 alexaServer + "/api/bluetooth/disconnect-sink/" + device.deviceType + "/" + device.serialNumber, "",
1257 true, true, null, 0);
1260 alexaServer + "/api/bluetooth/pair-sink/" + device.deviceType + "/" + device.serialNumber,
1261 "{\"bluetoothDeviceAddress\":\"" + address + "\"}", true, true, null, 0);
1265 private @Nullable String getCustomerId(@Nullable String defaultId) {
1266 String accountCustomerId = this.accountCustomerId;
1267 return accountCustomerId == null || accountCustomerId.isEmpty() ? defaultId : accountCustomerId;
1270 public void playRadio(Device device, @Nullable String stationId)
1271 throws IOException, URISyntaxException, InterruptedException {
1272 if (stationId == null || stationId.isEmpty()) {
1273 command(device, "{\"type\":\"PauseCommand\"}");
1276 alexaServer + "/api/tunein/queue-and-play?deviceSerialNumber=" + device.serialNumber
1277 + "&deviceType=" + device.deviceType + "&guideId=" + stationId
1278 + "&contentType=station&callSign=&mediaOwnerCustomerId="
1279 + getCustomerId(device.deviceOwnerCustomerId),
1280 "", true, true, null, 0);
1284 public void playAmazonMusicTrack(Device device, @Nullable String trackId)
1285 throws IOException, URISyntaxException, InterruptedException {
1286 if (trackId == null || trackId.isEmpty()) {
1287 command(device, "{\"type\":\"PauseCommand\"}");
1289 String command = "{\"trackId\":\"" + trackId + "\",\"playQueuePrime\":true}";
1291 alexaServer + "/api/cloudplayer/queue-and-play?deviceSerialNumber=" + device.serialNumber
1292 + "&deviceType=" + device.deviceType + "&mediaOwnerCustomerId="
1293 + getCustomerId(device.deviceOwnerCustomerId) + "&shuffle=false",
1294 command, true, true, null, 0);
1298 public void playAmazonMusicPlayList(Device device, @Nullable String playListId)
1299 throws IOException, URISyntaxException, InterruptedException {
1300 if (playListId == null || playListId.isEmpty()) {
1301 command(device, "{\"type\":\"PauseCommand\"}");
1303 String command = "{\"playlistId\":\"" + playListId + "\",\"playQueuePrime\":true}";
1305 alexaServer + "/api/cloudplayer/queue-and-play?deviceSerialNumber=" + device.serialNumber
1306 + "&deviceType=" + device.deviceType + "&mediaOwnerCustomerId="
1307 + getCustomerId(device.deviceOwnerCustomerId) + "&shuffle=false",
1308 command, true, true, null, 0);
1312 public void announcement(Device device, String speak, String bodyText, @Nullable String title,
1313 @Nullable Integer ttsVolume, @Nullable Integer standardVolume) {
1314 String plainSpeak = speak.replaceAll("<.+?>", " ").replaceAll("\\s+", " ").trim();
1315 String plainBody = bodyText.replaceAll("<.+?>", " ").replaceAll("\\s+", " ").trim();
1317 if (plainSpeak.isEmpty() && plainBody.isEmpty()) {
1318 // if there is neither a bodytext nor (except tags) a speaktext, we have nothing to announce
1322 // we lock announcements until we have finished adding this one
1323 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.ANNOUNCEMENT, k -> new ReentrantLock()));
1326 AnnouncementWrapper announcement = Objects.requireNonNull(announcements.computeIfAbsent(
1327 Objects.hash(speak, plainBody, title), k -> new AnnouncementWrapper(speak, plainBody, title)));
1328 announcement.devices.add(device);
1329 announcement.ttsVolumes.add(ttsVolume);
1330 announcement.standardVolumes.add(standardVolume);
1332 // schedule an announcement only if it has not been scheduled before
1333 timers.computeIfAbsent(TimerType.ANNOUNCEMENT,
1334 k -> scheduler.schedule(this::sendAnnouncement, 500, TimeUnit.MILLISECONDS));
1340 private void sendAnnouncement() {
1341 // we lock new announcements until we have dispatched everything
1342 Lock lock = locks.computeIfAbsent(TimerType.ANNOUNCEMENT, k -> new ReentrantLock());
1345 Iterator<AnnouncementWrapper> iterator = announcements.values().iterator();
1346 while (iterator.hasNext()) {
1347 AnnouncementWrapper announcement = iterator.next();
1349 List<Device> devices = announcement.devices;
1350 if (!devices.isEmpty()) {
1351 JsonAnnouncementContent content = new JsonAnnouncementContent(announcement);
1353 Map<String, Object> parameters = new HashMap<>();
1354 parameters.put("expireAfter", "PT5S");
1355 parameters.put("content", new JsonAnnouncementContent[] { content });
1356 parameters.put("target", new JsonAnnouncementTarget(devices));
1358 String customerId = getCustomerId(devices.get(0).deviceOwnerCustomerId);
1359 if (customerId != null) {
1360 parameters.put("customerId", customerId);
1362 executeSequenceCommandWithVolume(devices, "AlexaAnnouncement", parameters,
1363 announcement.ttsVolumes, announcement.standardVolumes);
1365 } catch (Exception e) {
1366 logger.warn("send announcement fails with unexpected error", e);
1371 // the timer is done anyway immediately after we unlock
1372 timers.remove(TimerType.ANNOUNCEMENT);
1377 public void textToSpeech(Device device, String text, @Nullable Integer ttsVolume,
1378 @Nullable Integer standardVolume) {
1379 if (text.replaceAll("<.+?>", "").replaceAll("\\s+", " ").trim().isEmpty()) {
1383 // we lock TTS until we have finished adding this one
1384 Lock lock = locks.computeIfAbsent(TimerType.TTS, k -> new ReentrantLock());
1387 TextToSpeech textToSpeech = Objects
1388 .requireNonNull(textToSpeeches.computeIfAbsent(Objects.hash(text), k -> new TextToSpeech(text)));
1389 textToSpeech.devices.add(device);
1390 textToSpeech.ttsVolumes.add(ttsVolume);
1391 textToSpeech.standardVolumes.add(standardVolume);
1392 // schedule a TTS only if it has not been scheduled before
1393 timers.computeIfAbsent(TimerType.TTS,
1394 k -> scheduler.schedule(this::sendTextToSpeech, 500, TimeUnit.MILLISECONDS));
1400 private void sendTextToSpeech() {
1401 // we lock new TTS until we have dispatched everything
1402 Lock lock = locks.computeIfAbsent(TimerType.TTS, k -> new ReentrantLock());
1405 Iterator<TextToSpeech> iterator = textToSpeeches.values().iterator();
1406 while (iterator.hasNext()) {
1407 TextToSpeech textToSpeech = iterator.next();
1409 List<Device> devices = textToSpeech.devices;
1410 if (!devices.isEmpty()) {
1411 String text = textToSpeech.text;
1412 Map<String, Object> parameters = new HashMap<>();
1413 parameters.put("textToSpeak", text);
1414 executeSequenceCommandWithVolume(devices, "Alexa.Speak", parameters, textToSpeech.ttsVolumes,
1415 textToSpeech.standardVolumes);
1417 } catch (Exception e) {
1418 logger.warn("send textToSpeech fails with unexpected error", e);
1423 // the timer is done anyway immediately after we unlock
1424 timers.remove(TimerType.TTS);
1429 public void volume(Device device, int vol) {
1430 // we lock volume until we have finished adding this one
1431 Lock lock = locks.computeIfAbsent(TimerType.VOLUME, k -> new ReentrantLock());
1434 Volume volume = Objects.requireNonNull(volumes.computeIfAbsent(vol, k -> new Volume(vol)));
1435 volume.devices.add(device);
1436 volume.volumes.add(vol);
1437 // schedule a TTS only if it has not been scheduled before
1438 timers.computeIfAbsent(TimerType.VOLUME,
1439 k -> scheduler.schedule(this::sendVolume, 500, TimeUnit.MILLISECONDS));
1445 private void sendVolume() {
1446 // we lock new volume until we have dispatched everything
1447 Lock lock = locks.computeIfAbsent(TimerType.VOLUME, k -> new ReentrantLock());
1450 Iterator<Volume> iterator = volumes.values().iterator();
1451 while (iterator.hasNext()) {
1452 Volume volume = iterator.next();
1454 List<Device> devices = volume.devices;
1455 if (!devices.isEmpty()) {
1456 executeSequenceCommandWithVolume(devices, null, Map.of(), volume.volumes, List.of());
1458 } catch (Exception e) {
1459 logger.warn("send volume fails with unexpected error", e);
1464 // the timer is done anyway immediately after we unlock
1465 timers.remove(TimerType.VOLUME);
1470 private void executeSequenceCommandWithVolume(List<Device> devices, @Nullable String command,
1471 Map<String, Object> parameters, List<@Nullable Integer> ttsVolumes,
1472 List<@Nullable Integer> standardVolumes) {
1473 JsonArray serialNodesToExecute = new JsonArray();
1474 JsonArray ttsVolumeNodesToExecute = new JsonArray();
1475 for (int i = 0; i < devices.size(); i++) {
1476 Integer ttsVolume = ttsVolumes.size() > i ? ttsVolumes.get(i) : null;
1477 Integer standardVolume = standardVolumes.size() > i ? standardVolumes.get(i) : null;
1478 if (ttsVolume != null && (standardVolume != null || !ttsVolume.equals(standardVolume))) {
1479 ttsVolumeNodesToExecute.add(
1480 createExecutionNode(devices.get(i), "Alexa.DeviceControls.Volume", Map.of("value", ttsVolume)));
1483 if (ttsVolumeNodesToExecute.size() > 0) {
1484 JsonObject parallelNodesToExecute = new JsonObject();
1485 parallelNodesToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1486 parallelNodesToExecute.add("nodesToExecute", ttsVolumeNodesToExecute);
1487 serialNodesToExecute.add(parallelNodesToExecute);
1490 if (command != null && !parameters.isEmpty()) {
1491 JsonArray commandNodesToExecute = new JsonArray();
1492 if ("Alexa.Speak".equals(command)) {
1493 for (Device device : devices) {
1494 commandNodesToExecute.add(createExecutionNode(device, command, parameters));
1497 commandNodesToExecute.add(createExecutionNode(devices.get(0), command, parameters));
1499 if (commandNodesToExecute.size() > 0) {
1500 JsonObject parallelNodesToExecute = new JsonObject();
1501 parallelNodesToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1502 parallelNodesToExecute.add("nodesToExecute", commandNodesToExecute);
1503 serialNodesToExecute.add(parallelNodesToExecute);
1507 JsonArray standardVolumeNodesToExecute = new JsonArray();
1508 for (int i = 0; i < devices.size(); i++) {
1509 Integer ttsVolume = ttsVolumes.size() > i ? ttsVolumes.get(i) : null;
1510 Integer standardVolume = standardVolumes.size() > i ? standardVolumes.get(i) : null;
1511 if (ttsVolume != null && standardVolume != null && !ttsVolume.equals(standardVolume)) {
1512 standardVolumeNodesToExecute.add(createExecutionNode(devices.get(i), "Alexa.DeviceControls.Volume",
1513 Map.of("value", standardVolume)));
1516 if (standardVolumeNodesToExecute.size() > 0) {
1517 JsonObject parallelNodesToExecute = new JsonObject();
1518 parallelNodesToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1519 parallelNodesToExecute.add("nodesToExecute", standardVolumeNodesToExecute);
1520 serialNodesToExecute.add(parallelNodesToExecute);
1523 if (serialNodesToExecute.size() > 0) {
1524 executeSequenceNodes(devices, serialNodesToExecute, false);
1528 // commands: Alexa.Weather.Play, Alexa.Traffic.Play, Alexa.FlashBriefing.Play,
1529 // Alexa.GoodMorning.Play,
1530 // Alexa.SingASong.Play, Alexa.TellStory.Play, Alexa.Speak (textToSpeach)
1531 public void executeSequenceCommand(Device device, String command, Map<String, Object> parameters) {
1532 JsonObject nodeToExecute = createExecutionNode(device, command, parameters);
1533 executeSequenceNode(List.of(device), nodeToExecute);
1536 private void executeSequenceNode(List<Device> devices, JsonObject nodeToExecute) {
1537 QueueObject queueObject = new QueueObject();
1538 queueObject.devices = devices;
1539 queueObject.nodeToExecute = nodeToExecute;
1540 String serialNumbers = "";
1541 for (Device device : devices) {
1542 String serialNumber = device.serialNumber;
1543 if (serialNumber != null) {
1544 Objects.requireNonNull(this.devices.computeIfAbsent(serialNumber, k -> new LinkedBlockingQueue<>()))
1545 .offer(queueObject);
1546 serialNumbers = serialNumbers + device.serialNumber + " ";
1549 logger.debug("added {} device {}", queueObject.hashCode(), serialNumbers);
1552 private void handleExecuteSequenceNode() {
1553 Lock lock = locks.computeIfAbsent(TimerType.DEVICES, k -> new ReentrantLock());
1554 if (lock.tryLock()) {
1556 for (String serialNumber : devices.keySet()) {
1557 LinkedBlockingQueue<QueueObject> queueObjects = devices.get(serialNumber);
1558 if (queueObjects != null) {
1559 QueueObject queueObject = queueObjects.peek();
1560 if (queueObject != null) {
1561 Future<?> future = queueObject.future;
1562 if (future == null || future.isDone()) {
1563 boolean execute = true;
1565 for (Device tmpDevice : queueObject.devices) {
1566 if (!serialNumber.equals(tmpDevice.serialNumber)) {
1567 LinkedBlockingQueue<QueueObject> tmpQueueObjects = devices
1568 .get(tmpDevice.serialNumber);
1569 if (tmpQueueObjects != null) {
1570 QueueObject tmpQueueObject = tmpQueueObjects.peek();
1571 Future<?> tmpFuture = tmpQueueObject.future;
1572 if (!queueObject.equals(tmpQueueObject)
1573 || (tmpFuture != null && !tmpFuture.isDone())) {
1577 serial = serial + tmpDevice.serialNumber + " ";
1582 queueObject.future = scheduler.submit(() -> queuedExecuteSequenceNode(queueObject));
1583 logger.debug("thread {} device {}", queueObject.hashCode(), serial);
1595 private void queuedExecuteSequenceNode(QueueObject queueObject) {
1596 JsonObject nodeToExecute = queueObject.nodeToExecute;
1597 ExecutionNodeObject executionNodeObject = getExecutionNodeObject(nodeToExecute);
1598 if (executionNodeObject == null) {
1599 logger.debug("executionNodeObject empty, removing without execution");
1600 removeObjectFromQueueAfterExecutionCompletion(queueObject);
1603 List<String> types = executionNodeObject.types;
1605 if (types.contains("Alexa.DeviceControls.Volume")) {
1608 if (types.contains("Announcement")) {
1614 JsonObject sequenceJson = new JsonObject();
1615 sequenceJson.addProperty("@type", "com.amazon.alexa.behaviors.model.Sequence");
1616 sequenceJson.add("startNode", nodeToExecute);
1618 JsonStartRoutineRequest request = new JsonStartRoutineRequest();
1619 request.sequenceJson = gson.toJson(sequenceJson);
1620 String json = gson.toJson(request);
1622 Map<String, String> headers = new HashMap<>();
1623 headers.put("Routines-Version", "1.1.218665");
1625 String text = executionNodeObject.text;
1627 text = text.replaceAll("<.+?>", " ").replaceAll("\\s+", " ").trim();
1628 delay += text.length() * 150;
1631 makeRequest("POST", alexaServer + "/api/behaviors/preview", json, true, true, null, 3);
1633 Thread.sleep(delay);
1634 } catch (IOException | URISyntaxException | InterruptedException e) {
1635 logger.warn("execute sequence node fails with unexpected error", e);
1637 removeObjectFromQueueAfterExecutionCompletion(queueObject);
1641 private void removeObjectFromQueueAfterExecutionCompletion(QueueObject queueObject) {
1643 for (Device device : queueObject.devices) {
1644 String serialNumber = device.serialNumber;
1645 if (serialNumber != null) {
1646 LinkedBlockingQueue<?> queue = devices.get(serialNumber);
1647 if (queue != null) {
1648 queue.remove(queueObject);
1650 serial = serial + serialNumber + " ";
1653 logger.debug("removed {} device {}", queueObject.hashCode(), serial);
1656 private void executeSequenceNodes(List<Device> devices, JsonArray nodesToExecute, boolean parallel) {
1657 JsonObject serialNode = new JsonObject();
1659 serialNode.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1661 serialNode.addProperty("@type", "com.amazon.alexa.behaviors.model.SerialNode");
1664 serialNode.add("nodesToExecute", nodesToExecute);
1666 executeSequenceNode(devices, serialNode);
1669 private JsonObject createExecutionNode(@Nullable Device device, String command, Map<String, Object> parameters) {
1670 JsonObject operationPayload = new JsonObject();
1671 if (device != null) {
1672 operationPayload.addProperty("deviceType", device.deviceType);
1673 operationPayload.addProperty("deviceSerialNumber", device.serialNumber);
1674 operationPayload.addProperty("locale", "");
1675 operationPayload.addProperty("customerId", getCustomerId(device.deviceOwnerCustomerId));
1677 for (String key : parameters.keySet()) {
1678 Object value = parameters.get(key);
1679 if (value instanceof String) {
1680 operationPayload.addProperty(key, (String) value);
1681 } else if (value instanceof Number) {
1682 operationPayload.addProperty(key, (Number) value);
1683 } else if (value instanceof Boolean) {
1684 operationPayload.addProperty(key, (Boolean) value);
1685 } else if (value instanceof Character) {
1686 operationPayload.addProperty(key, (Character) value);
1688 operationPayload.add(key, gson.toJsonTree(value));
1692 JsonObject nodeToExecute = new JsonObject();
1693 nodeToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode");
1694 nodeToExecute.addProperty("type", command);
1695 nodeToExecute.add("operationPayload", operationPayload);
1696 return nodeToExecute;
1700 private ExecutionNodeObject getExecutionNodeObject(JsonObject nodeToExecute) {
1701 ExecutionNodeObject executionNodeObject = new ExecutionNodeObject();
1702 if (nodeToExecute.has("nodesToExecute")) {
1703 JsonArray serialNodesToExecute = nodeToExecute.getAsJsonArray("nodesToExecute");
1704 if (serialNodesToExecute != null && serialNodesToExecute.size() > 0) {
1705 for (int i = 0; i < serialNodesToExecute.size(); i++) {
1706 JsonObject serialNodesToExecuteJsonObject = serialNodesToExecute.get(i).getAsJsonObject();
1707 if (serialNodesToExecuteJsonObject.has("nodesToExecute")) {
1708 JsonArray parallelNodesToExecute = serialNodesToExecuteJsonObject
1709 .getAsJsonArray("nodesToExecute");
1710 if (parallelNodesToExecute != null && parallelNodesToExecute.size() > 0) {
1711 JsonObject parallelNodesToExecuteJsonObject = parallelNodesToExecute.get(0)
1713 if (processNodesToExecuteJsonObject(executionNodeObject,
1714 parallelNodesToExecuteJsonObject)) {
1719 if (processNodesToExecuteJsonObject(executionNodeObject, serialNodesToExecuteJsonObject)) {
1727 return executionNodeObject;
1730 private boolean processNodesToExecuteJsonObject(ExecutionNodeObject executionNodeObject,
1731 JsonObject nodesToExecuteJsonObject) {
1732 if (nodesToExecuteJsonObject.has("type")) {
1733 executionNodeObject.types.add(nodesToExecuteJsonObject.get("type").getAsString());
1734 if (nodesToExecuteJsonObject.has("operationPayload")) {
1735 JsonObject operationPayload = nodesToExecuteJsonObject.getAsJsonObject("operationPayload");
1736 if (operationPayload != null) {
1737 if (operationPayload.has("textToSpeak")) {
1738 executionNodeObject.text = operationPayload.get("textToSpeak").getAsString();
1740 } else if (operationPayload.has("content")) {
1741 JsonArray content = operationPayload.getAsJsonArray("content");
1742 if (content != null && content.size() > 0) {
1743 JsonObject contentJsonObject = content.get(0).getAsJsonObject();
1744 if (contentJsonObject.has("speak")) {
1745 JsonObject speak = contentJsonObject.getAsJsonObject("speak");
1746 if (speak != null && speak.has("value")) {
1747 executionNodeObject.text = speak.get("value").getAsString();
1759 public void startRoutine(Device device, String utterance)
1760 throws IOException, URISyntaxException, InterruptedException {
1761 JsonAutomation found = null;
1762 String deviceLocale = "";
1763 JsonAutomation[] routines = getRoutines();
1764 if (routines == null) {
1767 for (JsonAutomation routine : routines) {
1768 if (routine != null) {
1769 Trigger[] triggers = routine.triggers;
1770 if (triggers != null && routine.sequence != null) {
1771 for (JsonAutomation.Trigger trigger : triggers) {
1772 if (trigger == null) {
1775 Payload payload = trigger.payload;
1776 if (payload == null) {
1779 String payloadUtterance = payload.utterance;
1780 if (payloadUtterance != null && payloadUtterance.equalsIgnoreCase(utterance)) {
1782 deviceLocale = payload.locale;
1789 if (found != null) {
1790 String sequenceJson = gson.toJson(found.sequence);
1792 JsonStartRoutineRequest request = new JsonStartRoutineRequest();
1793 request.behaviorId = found.automationId;
1796 // "deviceType":"ALEXA_CURRENT_DEVICE_TYPE"
1797 String deviceType = "\"deviceType\":\"ALEXA_CURRENT_DEVICE_TYPE\"";
1798 String newDeviceType = "\"deviceType\":\"" + device.deviceType + "\"";
1799 sequenceJson = sequenceJson.replace(deviceType.subSequence(0, deviceType.length()),
1800 newDeviceType.subSequence(0, newDeviceType.length()));
1802 // "deviceSerialNumber":"ALEXA_CURRENT_DSN"
1803 String deviceSerial = "\"deviceSerialNumber\":\"ALEXA_CURRENT_DSN\"";
1804 String newDeviceSerial = "\"deviceSerialNumber\":\"" + device.serialNumber + "\"";
1805 sequenceJson = sequenceJson.replace(deviceSerial.subSequence(0, deviceSerial.length()),
1806 newDeviceSerial.subSequence(0, newDeviceSerial.length()));
1808 // "customerId": "ALEXA_CUSTOMER_ID"
1809 String customerId = "\"customerId\":\"ALEXA_CUSTOMER_ID\"";
1810 String newCustomerId = "\"customerId\":\"" + getCustomerId(device.deviceOwnerCustomerId) + "\"";
1811 sequenceJson = sequenceJson.replace(customerId.subSequence(0, customerId.length()),
1812 newCustomerId.subSequence(0, newCustomerId.length()));
1814 // "locale": "ALEXA_CURRENT_LOCALE"
1815 String locale = "\"locale\":\"ALEXA_CURRENT_LOCALE\"";
1816 String newlocale = deviceLocale != null && !deviceLocale.isEmpty() ? "\"locale\":\"" + deviceLocale + "\""
1817 : "\"locale\":null";
1818 sequenceJson = sequenceJson.replace(locale.subSequence(0, locale.length()),
1819 newlocale.subSequence(0, newlocale.length()));
1821 request.sequenceJson = sequenceJson;
1823 String requestJson = gson.toJson(request);
1824 makeRequest("POST", alexaServer + "/api/behaviors/preview", requestJson, true, true, null, 3);
1826 logger.warn("Routine {} not found", utterance);
1830 public @Nullable JsonAutomation @Nullable [] getRoutines()
1831 throws IOException, URISyntaxException, InterruptedException {
1832 String json = makeRequestAndReturnString(alexaServer + "/api/behaviors/automations?limit=2000");
1833 JsonAutomation[] result = parseJson(json, JsonAutomation[].class);
1837 public JsonFeed[] getEnabledFlashBriefings() throws IOException, URISyntaxException, InterruptedException {
1838 String json = makeRequestAndReturnString(alexaServer + "/api/content-skills/enabled-feeds");
1839 JsonEnabledFeeds result = parseJson(json, JsonEnabledFeeds.class);
1840 if (result == null) {
1841 return new JsonFeed[0];
1843 JsonFeed[] enabledFeeds = result.enabledFeeds;
1844 if (enabledFeeds != null) {
1845 return enabledFeeds;
1847 return new JsonFeed[0];
1850 public void setEnabledFlashBriefings(JsonFeed[] enabledFlashBriefing)
1851 throws IOException, URISyntaxException, InterruptedException {
1852 JsonEnabledFeeds enabled = new JsonEnabledFeeds();
1853 enabled.enabledFeeds = enabledFlashBriefing;
1854 String json = gsonWithNullSerialization.toJson(enabled);
1855 makeRequest("POST", alexaServer + "/api/content-skills/enabled-feeds", json, true, true, null, 0);
1858 public JsonNotificationSound[] getNotificationSounds(Device device)
1859 throws IOException, URISyntaxException, InterruptedException {
1860 String json = makeRequestAndReturnString(
1861 alexaServer + "/api/notification/sounds?deviceSerialNumber=" + device.serialNumber + "&deviceType="
1862 + device.deviceType + "&softwareVersion=" + device.softwareVersion);
1863 JsonNotificationSounds result = parseJson(json, JsonNotificationSounds.class);
1864 if (result == null) {
1865 return new JsonNotificationSound[0];
1867 JsonNotificationSound[] notificationSounds = result.notificationSounds;
1868 if (notificationSounds != null) {
1869 return notificationSounds;
1871 return new JsonNotificationSound[0];
1874 public JsonNotificationResponse[] notifications() throws IOException, URISyntaxException, InterruptedException {
1875 String response = makeRequestAndReturnString(alexaServer + "/api/notifications");
1876 JsonNotificationsResponse result = parseJson(response, JsonNotificationsResponse.class);
1877 if (result == null) {
1878 return new JsonNotificationResponse[0];
1880 JsonNotificationResponse[] notifications = result.notifications;
1881 if (notifications == null) {
1882 return new JsonNotificationResponse[0];
1884 return notifications;
1887 public @Nullable JsonNotificationResponse notification(Device device, String type, @Nullable String label,
1888 @Nullable JsonNotificationSound sound) throws IOException, URISyntaxException, InterruptedException {
1889 Date date = new Date(new Date().getTime());
1890 long createdDate = date.getTime();
1891 Date alarm = new Date(createdDate + 5000); // add 5 seconds, because amazon does not except calls for times in
1892 // the past (compared with the server time)
1893 long alarmTime = alarm.getTime();
1895 JsonNotificationRequest request = new JsonNotificationRequest();
1896 request.type = type;
1897 request.deviceSerialNumber = device.serialNumber;
1898 request.deviceType = device.deviceType;
1899 request.createdDate = createdDate;
1900 request.alarmTime = alarmTime;
1901 request.reminderLabel = label;
1902 request.sound = sound;
1903 request.originalDate = new SimpleDateFormat("yyyy-MM-dd").format(alarm);
1904 request.originalTime = new SimpleDateFormat("HH:mm:ss.SSSS").format(alarm);
1905 request.type = type;
1906 request.id = "create" + type;
1908 String data = gsonWithNullSerialization.toJson(request);
1909 String response = makeRequestAndReturnString("PUT", alexaServer + "/api/notifications/createReminder", data,
1911 JsonNotificationResponse result = parseJson(response, JsonNotificationResponse.class);
1915 public void stopNotification(JsonNotificationResponse notification)
1916 throws IOException, URISyntaxException, InterruptedException {
1917 makeRequestAndReturnString("DELETE", alexaServer + "/api/notifications/" + notification.id, null, true, null);
1920 public @Nullable JsonNotificationResponse getNotificationState(JsonNotificationResponse notification)
1921 throws IOException, URISyntaxException, InterruptedException {
1922 String response = makeRequestAndReturnString("GET", alexaServer + "/api/notifications/" + notification.id, null,
1924 JsonNotificationResponse result = parseJson(response, JsonNotificationResponse.class);
1928 public List<JsonMusicProvider> getMusicProviders() {
1930 Map<String, String> headers = new HashMap<>();
1931 headers.put("Routines-Version", "1.1.218665");
1932 String response = makeRequestAndReturnString("GET",
1933 alexaServer + "/api/behaviors/entities?skillId=amzn1.ask.1p.music", null, true, headers);
1934 if (!response.isEmpty()) {
1935 JsonMusicProvider[] result = parseJson(response, JsonMusicProvider[].class);
1936 return Arrays.asList(result);
1938 } catch (IOException | URISyntaxException | InterruptedException e) {
1939 logger.warn("getMusicProviders fails: {}", e.getMessage());
1944 public void playMusicVoiceCommand(Device device, String providerId, String voiceCommand)
1945 throws IOException, URISyntaxException, InterruptedException {
1946 JsonPlaySearchPhraseOperationPayload payload = new JsonPlaySearchPhraseOperationPayload();
1947 payload.customerId = getCustomerId(device.deviceOwnerCustomerId);
1948 payload.locale = "ALEXA_CURRENT_LOCALE";
1949 payload.musicProviderId = providerId;
1950 payload.searchPhrase = voiceCommand;
1952 String playloadString = gson.toJson(payload);
1954 JsonObject postValidationJson = new JsonObject();
1956 postValidationJson.addProperty("type", "Alexa.Music.PlaySearchPhrase");
1957 postValidationJson.addProperty("operationPayload", playloadString);
1959 String postDataValidate = postValidationJson.toString();
1961 String validateResultJson = makeRequestAndReturnString("POST",
1962 alexaServer + "/api/behaviors/operation/validate", postDataValidate, true, null);
1964 if (!validateResultJson.isEmpty()) {
1965 JsonPlayValidationResult validationResult = parseJson(validateResultJson, JsonPlayValidationResult.class);
1966 if (validationResult != null) {
1967 JsonPlaySearchPhraseOperationPayload validatedOperationPayload = validationResult.operationPayload;
1968 if (validatedOperationPayload != null) {
1969 payload.sanitizedSearchPhrase = validatedOperationPayload.sanitizedSearchPhrase;
1970 payload.searchPhrase = validatedOperationPayload.searchPhrase;
1975 payload.locale = null;
1976 payload.deviceSerialNumber = device.serialNumber;
1977 payload.deviceType = device.deviceType;
1979 JsonObject sequenceJson = new JsonObject();
1980 sequenceJson.addProperty("@type", "com.amazon.alexa.behaviors.model.Sequence");
1981 JsonObject startNodeJson = new JsonObject();
1982 startNodeJson.addProperty("@type", "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode");
1983 startNodeJson.addProperty("type", "Alexa.Music.PlaySearchPhrase");
1984 startNodeJson.add("operationPayload", gson.toJsonTree(payload));
1985 sequenceJson.add("startNode", startNodeJson);
1987 JsonStartRoutineRequest startRoutineRequest = new JsonStartRoutineRequest();
1988 startRoutineRequest.sequenceJson = sequenceJson.toString();
1989 startRoutineRequest.status = null;
1991 String postData = gson.toJson(startRoutineRequest);
1992 makeRequest("POST", alexaServer + "/api/behaviors/preview", postData, true, true, null, 3);
1995 public @Nullable JsonEqualizer getEqualizer(Device device)
1996 throws IOException, URISyntaxException, InterruptedException {
1997 String json = makeRequestAndReturnString(
1998 alexaServer + "/api/equalizer/" + device.serialNumber + "/" + device.deviceType);
1999 return parseJson(json, JsonEqualizer.class);
2002 public void setEqualizer(Device device, JsonEqualizer settings)
2003 throws IOException, URISyntaxException, InterruptedException {
2004 String postData = gson.toJson(settings);
2005 makeRequest("POST", alexaServer + "/api/equalizer/" + device.serialNumber + "/" + device.deviceType, postData,
2006 true, true, null, 0);
2009 public static class AnnouncementWrapper {
2010 public List<Device> devices = new ArrayList<>();
2011 public String speak;
2012 public String bodyText;
2013 public @Nullable String title;
2014 public List<@Nullable Integer> ttsVolumes = new ArrayList<>();
2015 public List<@Nullable Integer> standardVolumes = new ArrayList<>();
2017 public AnnouncementWrapper(String speak, String bodyText, @Nullable String title) {
2019 this.bodyText = bodyText;
2024 private static class TextToSpeech {
2025 public List<Device> devices = new ArrayList<>();
2027 public List<@Nullable Integer> ttsVolumes = new ArrayList<>();
2028 public List<@Nullable Integer> standardVolumes = new ArrayList<>();
2030 public TextToSpeech(String text) {
2035 private static class Volume {
2036 public List<Device> devices = new ArrayList<>();
2038 public List<@Nullable Integer> volumes = new ArrayList<>();
2040 public Volume(int volume) {
2041 this.volume = volume;
2045 private static class QueueObject {
2046 public @Nullable Future<?> future;
2047 public List<Device> devices = List.of();
2048 public JsonObject nodeToExecute = new JsonObject();
2051 private static class ExecutionNodeObject {
2052 public List<String> types = new ArrayList<>();