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.OutputStream;
18 import java.net.CookieManager;
19 import java.net.CookieStore;
20 import java.net.HttpCookie;
22 import java.net.URISyntaxException;
24 import java.net.URLDecoder;
25 import java.net.URLEncoder;
26 import java.nio.charset.StandardCharsets;
27 import java.text.SimpleDateFormat;
28 import java.util.ArrayList;
29 import java.util.Arrays;
30 import java.util.Base64;
31 import java.util.Collections;
32 import java.util.Date;
33 import java.util.HashMap;
34 import java.util.Iterator;
35 import java.util.LinkedHashMap;
36 import java.util.List;
38 import java.util.Objects;
39 import java.util.Random;
40 import java.util.Scanner;
42 import java.util.concurrent.LinkedBlockingQueue;
43 import java.util.concurrent.ScheduledExecutorService;
44 import java.util.concurrent.ScheduledFuture;
45 import java.util.concurrent.TimeUnit;
46 import java.util.concurrent.atomic.AtomicBoolean;
47 import java.util.regex.Matcher;
48 import java.util.regex.Pattern;
49 import java.util.stream.Collectors;
50 import java.util.zip.GZIPInputStream;
52 import javax.net.ssl.HttpsURLConnection;
54 import org.eclipse.jdt.annotation.NonNull;
55 import org.eclipse.jdt.annotation.NonNullByDefault;
56 import org.eclipse.jdt.annotation.Nullable;
57 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonActivities;
58 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonActivities.Activity;
59 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAnnouncementContent;
60 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAnnouncementTarget;
61 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAnnouncementTarget.TargetDevice;
62 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAscendingAlarm;
63 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAscendingAlarm.AscendingAlarmModel;
64 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAutomation;
65 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAutomation.Payload;
66 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAutomation.Trigger;
67 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBluetoothStates;
68 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBootstrapResult;
69 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBootstrapResult.Authentication;
70 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDeviceNotificationState;
71 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDeviceNotificationState.DeviceNotificationState;
72 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDevices;
73 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDevices.Device;
74 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonEnabledFeeds;
75 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonEqualizer;
76 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonExchangeTokenResponse;
77 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonExchangeTokenResponse.Cookie;
78 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonFeed;
79 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonMediaState;
80 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonMusicProvider;
81 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNetworkDetails;
82 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationRequest;
83 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationResponse;
84 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationSound;
85 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationSounds;
86 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationsResponse;
87 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlaySearchPhraseOperationPayload;
88 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlayValidationResult;
89 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlayerState;
90 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlaylists;
91 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppRequest;
92 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse;
93 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Bearer;
94 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.DeviceInfo;
95 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Extensions;
96 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Response;
97 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Success;
98 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Tokens;
99 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRenewTokenResponse;
100 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonSmartHomeDevices.SmartHomeDevice;
101 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonSmartHomeGroups.SmartHomeGroup;
102 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonStartRoutineRequest;
103 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonUsersMeResponse;
104 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonWakeWords;
105 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonWakeWords.WakeWord;
106 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonWebSiteCookie;
107 import org.openhab.binding.amazonechocontrol.internal.jsons.SmartHomeBaseDevice;
108 import org.openhab.core.common.ThreadPoolManager;
109 import org.openhab.core.util.HexUtils;
110 import org.slf4j.Logger;
111 import org.slf4j.LoggerFactory;
113 import com.google.gson.Gson;
114 import com.google.gson.GsonBuilder;
115 import com.google.gson.JsonArray;
116 import com.google.gson.JsonElement;
117 import com.google.gson.JsonObject;
118 import com.google.gson.JsonParseException;
119 import com.google.gson.JsonSyntaxException;
122 * The {@link Connection} is responsible for the connection to the amazon server
123 * and handling of the commands
125 * @author Michael Geramb - Initial contribution
128 public class Connection {
129 private static final String THING_THREADPOOL_NAME = "thingHandler";
130 private static final long EXPIRES_IN = 432000; // five days
131 private static final Pattern CHARSET_PATTERN = Pattern.compile("(?i)\\bcharset=\\s*\"?([^\\s;\"]*)");
132 private static final String DEVICE_TYPE = "A2IVLV5VM2W81";
134 protected final ScheduledExecutorService scheduler = ThreadPoolManager.getScheduledPool(THING_THREADPOOL_NAME);
136 private final Logger logger = LoggerFactory.getLogger(Connection.class);
138 private final Random rand = new Random();
139 private final CookieManager cookieManager = new CookieManager();
140 private String amazonSite = "amazon.com";
141 private String alexaServer = "https://alexa.amazon.com";
142 private final String userAgent;
144 private String serial;
145 private String deviceId;
147 private @Nullable String refreshToken;
148 private @Nullable Date loginTime;
149 private @Nullable Date verifyTime;
150 private long renewTime = 0;
151 private @Nullable String deviceName;
152 private @Nullable String accountCustomerId;
153 private @Nullable String customerName;
155 private Map<Integer, Announcement> announcements = new LinkedHashMap<>();
156 private Map<Integer, TextToSpeech> textToSpeeches = new LinkedHashMap<>();
157 private Map<Integer, Volume> volumes = new LinkedHashMap<>();
158 private @Nullable ScheduledFuture<?> announcementTimer;
159 private @Nullable ScheduledFuture<?> textToSpeechTimer;
160 private @Nullable ScheduledFuture<?> volumeTimer;
162 private final Gson gson;
163 private final Gson gsonWithNullSerialization;
165 private Map<Device, QueueObject> singles = Collections.synchronizedMap(new LinkedHashMap<>());
166 private Map<Device, QueueObject> groups = Collections.synchronizedMap(new LinkedHashMap<>());
167 public @Nullable ScheduledFuture<?> singleGroupTimer;
169 public Connection(@Nullable Connection oldConnection, Gson gson) {
172 String serial = null;
173 String deviceId = null;
174 if (oldConnection != null) {
175 deviceId = oldConnection.getDeviceId();
176 frc = oldConnection.getFrc();
177 serial = oldConnection.getSerial();
183 byte[] frcBinary = new byte[313];
184 rand.nextBytes(frcBinary);
185 this.frc = Base64.getEncoder().encodeToString(frcBinary);
187 if (serial != null) {
188 this.serial = serial;
191 byte[] serialBinary = new byte[16];
192 rand.nextBytes(serialBinary);
193 this.serial = HexUtils.bytesToHex(serialBinary);
195 if (deviceId != null) {
196 this.deviceId = deviceId;
198 this.deviceId = generateDeviceId();
202 this.userAgent = "AmazonWebView/Amazon Alexa/2.2.223830.0/iOS/11.4.1/iPhone";
204 // setAmazonSite(amazonSite);
205 GsonBuilder gsonBuilder = new GsonBuilder();
206 gsonWithNullSerialization = gsonBuilder.create();
210 * Generate a new device id
212 * The device id consists of 16 random bytes in upper-case hex format, a # as separator and a fixed DEVICE_TYPE
214 * @return a string containing the new device-id
216 private String generateDeviceId() {
217 byte[] bytes = new byte[16];
218 rand.nextBytes(bytes);
219 String hexStr = HexUtils.bytesToHex(bytes).toUpperCase() + "#" + DEVICE_TYPE;
220 return HexUtils.bytesToHex(hexStr.getBytes());
224 * Check if deviceId is valid (consisting of hex(hex(16 random bytes)) + "#" + DEVICE_TYPE)
226 * @param deviceId the deviceId
227 * @return true if valid, false if invalid
229 private boolean checkDeviceIdIsValid(@Nullable String deviceId) {
230 if (deviceId != null && deviceId.matches("^[0-9a-fA-F]{92}$")) {
231 String hexString = new String(HexUtils.hexToBytes(deviceId));
232 if (hexString.matches("^[0-9A-F]{32}#" + DEVICE_TYPE + "$")) {
239 private void setAmazonSite(@Nullable String amazonSite) {
240 String correctedAmazonSite = amazonSite != null ? amazonSite : "amazon.com";
241 if (correctedAmazonSite.toLowerCase().startsWith("http://")) {
242 correctedAmazonSite = correctedAmazonSite.substring(7);
244 if (correctedAmazonSite.toLowerCase().startsWith("https://")) {
245 correctedAmazonSite = correctedAmazonSite.substring(8);
247 if (correctedAmazonSite.toLowerCase().startsWith("www.")) {
248 correctedAmazonSite = correctedAmazonSite.substring(4);
250 if (correctedAmazonSite.toLowerCase().startsWith("alexa.")) {
251 correctedAmazonSite = correctedAmazonSite.substring(6);
253 this.amazonSite = correctedAmazonSite;
254 alexaServer = "https://alexa." + this.amazonSite;
257 public @Nullable Date tryGetLoginTime() {
261 public @Nullable Date tryGetVerifyTime() {
265 public String getFrc() {
269 public String getSerial() {
273 public String getDeviceId() {
277 public String getAmazonSite() {
281 public String getAlexaServer() {
285 public String getDeviceName() {
286 String deviceName = this.deviceName;
287 if (deviceName == null) {
293 public String getCustomerId() {
294 String customerId = this.accountCustomerId;
295 if (customerId == null) {
301 public String getCustomerName() {
302 String customerName = this.customerName;
303 if (customerName == null) {
309 public boolean isSequenceNodeQueueRunning() {
310 return singles.values().stream().anyMatch(queueObject -> queueObject.queueRunning.get())
311 || groups.values().stream().anyMatch(queueObject -> queueObject.queueRunning.get());
314 public String serializeLoginData() {
315 Date loginTime = this.loginTime;
316 if (refreshToken == null || loginTime == null) {
319 StringBuilder builder = new StringBuilder();
320 builder.append("7\n"); // version
322 builder.append("\n");
323 builder.append(serial);
324 builder.append("\n");
325 builder.append(deviceId);
326 builder.append("\n");
327 builder.append(refreshToken);
328 builder.append("\n");
329 builder.append(amazonSite);
330 builder.append("\n");
331 builder.append(deviceName);
332 builder.append("\n");
333 builder.append(accountCustomerId);
334 builder.append("\n");
335 builder.append(loginTime.getTime());
336 builder.append("\n");
337 List<HttpCookie> cookies = cookieManager.getCookieStore().getCookies();
338 builder.append(cookies.size());
339 builder.append("\n");
340 for (HttpCookie cookie : cookies) {
341 writeValue(builder, cookie.getName());
342 writeValue(builder, cookie.getValue());
343 writeValue(builder, cookie.getComment());
344 writeValue(builder, cookie.getCommentURL());
345 writeValue(builder, cookie.getDomain());
346 writeValue(builder, cookie.getMaxAge());
347 writeValue(builder, cookie.getPath());
348 writeValue(builder, cookie.getPortlist());
349 writeValue(builder, cookie.getVersion());
350 writeValue(builder, cookie.getSecure());
351 writeValue(builder, cookie.getDiscard());
353 return builder.toString();
356 private void writeValue(StringBuilder builder, @Nullable Object value) {
361 builder.append("\n");
362 builder.append(value.toString());
364 builder.append("\n");
367 private String readValue(Scanner scanner) {
368 if (scanner.nextLine().equals("1")) {
369 String result = scanner.nextLine();
370 if (result != null) {
377 public boolean tryRestoreLogin(@Nullable String data, @Nullable String overloadedDomain) {
378 Date loginTime = tryRestoreSessionData(data, overloadedDomain);
379 if (loginTime != null) {
382 this.loginTime = loginTime;
385 } catch (IOException e) {
387 } catch (URISyntaxException e) {
393 private @Nullable Date tryRestoreSessionData(@Nullable String data, @Nullable String overloadedDomain) {
395 if (data == null || data.isEmpty()) {
398 Scanner scanner = new Scanner(data);
399 String version = scanner.nextLine();
400 // check if serialize version is supported
401 if (!"5".equals(version) && !"6".equals(version) && !"7".equals(version)) {
405 int intVersion = Integer.parseInt(version);
407 frc = scanner.nextLine();
408 serial = scanner.nextLine();
409 deviceId = scanner.nextLine();
411 // Recreate session and cookies
412 refreshToken = scanner.nextLine();
413 String domain = scanner.nextLine();
414 if (overloadedDomain != null) {
415 domain = overloadedDomain;
417 setAmazonSite(domain);
419 deviceName = scanner.nextLine();
421 if (intVersion > 5) {
422 String accountCustomerId = scanner.nextLine();
423 // Note: version 5 have wrong customer id serialized.
424 // Only use it, if it at least version 6 of serialization
425 if (intVersion > 6) {
426 if (!"null".equals(accountCustomerId)) {
427 this.accountCustomerId = accountCustomerId;
432 Date loginTime = new Date(Long.parseLong(scanner.nextLine()));
433 CookieStore cookieStore = cookieManager.getCookieStore();
434 cookieStore.removeAll();
436 Integer numberOfCookies = Integer.parseInt(scanner.nextLine());
437 for (Integer i = 0; i < numberOfCookies; i++) {
438 String name = readValue(scanner);
439 String value = readValue(scanner);
441 HttpCookie clientCookie = new HttpCookie(name, value);
442 clientCookie.setComment(readValue(scanner));
443 clientCookie.setCommentURL(readValue(scanner));
444 clientCookie.setDomain(readValue(scanner));
445 clientCookie.setMaxAge(Long.parseLong(readValue(scanner)));
446 clientCookie.setPath(readValue(scanner));
447 clientCookie.setPortlist(readValue(scanner));
448 clientCookie.setVersion(Integer.parseInt(readValue(scanner)));
449 clientCookie.setSecure(Boolean.parseBoolean(readValue(scanner)));
450 clientCookie.setDiscard(Boolean.parseBoolean(readValue(scanner)));
452 cookieStore.add(null, clientCookie);
458 String accountCustomerId = this.accountCustomerId;
459 if (accountCustomerId == null || accountCustomerId.isEmpty()) {
460 List<Device> devices = this.getDeviceList();
461 for (Device device : devices) {
462 final String serial = this.serial;
463 if (serial != null && serial.equals(device.serialNumber)) {
464 this.accountCustomerId = device.deviceOwnerCustomerId;
468 accountCustomerId = this.accountCustomerId;
469 if (accountCustomerId == null || accountCustomerId.isEmpty()) {
470 for (Device device : devices) {
471 if ("This Device".equals(device.accountName)) {
472 this.accountCustomerId = device.deviceOwnerCustomerId;
473 String serial = device.serialNumber;
474 if (serial != null) {
475 this.serial = serial;
482 } catch (URISyntaxException | IOException | ConnectionException e) {
483 logger.debug("Getting account customer Id failed", e);
488 private @Nullable Authentication tryGetBootstrap() throws IOException, URISyntaxException {
489 HttpsURLConnection connection = makeRequest("GET", alexaServer + "/api/bootstrap", null, false, false, null, 0);
490 String contentType = connection.getContentType();
491 if (connection.getResponseCode() == 200 && contentType != null
492 && contentType.toLowerCase().startsWith("application/json")) {
494 String bootstrapResultJson = convertStream(connection);
495 JsonBootstrapResult result = parseJson(bootstrapResultJson, JsonBootstrapResult.class);
496 if (result != null) {
497 Authentication authentication = result.authentication;
498 if (authentication != null && authentication.authenticated) {
499 this.customerName = authentication.customerName;
500 if (this.accountCustomerId == null) {
501 this.accountCustomerId = authentication.customerId;
503 return authentication;
506 } catch (JsonSyntaxException | IllegalStateException e) {
507 logger.info("No valid json received", e);
514 public String convertStream(HttpsURLConnection connection) throws IOException {
515 InputStream input = connection.getInputStream();
520 InputStream readerStream;
521 if ("gzip".equalsIgnoreCase(connection.getContentEncoding())) {
522 readerStream = new GZIPInputStream(connection.getInputStream());
524 readerStream = input;
526 String contentType = connection.getContentType();
527 String charSet = null;
528 if (contentType != null) {
529 Matcher m = CHARSET_PATTERN.matcher(contentType);
531 charSet = m.group(1).trim().toUpperCase();
535 Scanner inputScanner = charSet == null || charSet.isEmpty()
536 ? new Scanner(readerStream, StandardCharsets.UTF_8.name())
537 : new Scanner(readerStream, charSet);
538 Scanner scannerWithoutDelimiter = inputScanner.useDelimiter("\\A");
539 String result = scannerWithoutDelimiter.hasNext() ? scannerWithoutDelimiter.next() : null;
540 inputScanner.close();
541 scannerWithoutDelimiter.close();
543 if (result == null) {
549 public String makeRequestAndReturnString(String url) throws IOException, URISyntaxException {
550 return makeRequestAndReturnString("GET", url, null, false, null);
553 public String makeRequestAndReturnString(String verb, String url, @Nullable String postData, boolean json,
554 @Nullable Map<String, String> customHeaders) throws IOException, URISyntaxException {
555 HttpsURLConnection connection = makeRequest(verb, url, postData, json, true, customHeaders, 3);
556 String result = convertStream(connection);
557 logger.debug("Result of {} {}:{}", verb, url, result);
561 public HttpsURLConnection makeRequest(String verb, String url, @Nullable String postData, boolean json,
562 boolean autoredirect, @Nullable Map<String, String> customHeaders, int badRequestRepeats)
563 throws IOException, URISyntaxException {
564 String currentUrl = url;
565 int redirectCounter = 0;
566 int retryCounter = 0;
567 // loop for handling redirect and bad request, using automatic redirect is not
568 // possible, because all response headers must be catched
571 HttpsURLConnection connection = null;
573 logger.debug("Make request to {}", url);
574 connection = (HttpsURLConnection) new URL(currentUrl).openConnection();
575 connection.setRequestMethod(verb);
576 connection.setRequestProperty("Accept-Language", "en-US");
577 if (customHeaders == null || !customHeaders.containsKey("User-Agent")) {
578 connection.setRequestProperty("User-Agent", userAgent);
580 connection.setRequestProperty("Accept-Encoding", "gzip");
581 connection.setRequestProperty("DNT", "1");
582 connection.setRequestProperty("Upgrade-Insecure-Requests", "1");
583 if (customHeaders != null) {
584 for (String key : customHeaders.keySet()) {
585 String value = customHeaders.get(key);
586 if (value != null && !value.isEmpty()) {
587 connection.setRequestProperty(key, value);
591 connection.setInstanceFollowRedirects(false);
594 URI uri = connection.getURL().toURI();
596 if (customHeaders == null || !customHeaders.containsKey("Cookie")) {
597 StringBuilder cookieHeaderBuilder = new StringBuilder();
598 for (HttpCookie cookie : cookieManager.getCookieStore().get(uri)) {
599 if (cookieHeaderBuilder.length() > 0) {
600 cookieHeaderBuilder.append(";");
602 cookieHeaderBuilder.append(cookie.getName());
603 cookieHeaderBuilder.append("=");
604 cookieHeaderBuilder.append(cookie.getValue());
605 if (cookie.getName().equals("csrf")) {
606 connection.setRequestProperty("csrf", cookie.getValue());
610 if (cookieHeaderBuilder.length() > 0) {
611 String cookies = cookieHeaderBuilder.toString();
612 connection.setRequestProperty("Cookie", cookies);
615 if (postData != null) {
616 logger.debug("{}: {}", verb, postData);
618 byte[] postDataBytes = postData.getBytes(StandardCharsets.UTF_8);
619 int postDataLength = postDataBytes.length;
621 connection.setFixedLengthStreamingMode(postDataLength);
624 connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
626 connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
628 connection.setRequestProperty("Content-Length", Integer.toString(postDataLength));
629 if ("POST".equals(verb)) {
630 connection.setRequestProperty("Expect", "100-continue");
633 connection.setDoOutput(true);
634 OutputStream outputStream = connection.getOutputStream();
635 outputStream.write(postDataBytes);
636 outputStream.close();
639 code = connection.getResponseCode();
640 String location = null;
642 // handle response headers
643 Map<String, List<String>> headerFields = connection.getHeaderFields();
644 for (Map.Entry<String, List<String>> header : headerFields.entrySet()) {
645 String key = header.getKey();
646 if (key != null && !key.isEmpty()) {
647 if (key.equalsIgnoreCase("Set-Cookie")) {
649 for (String cookieHeader : header.getValue()) {
650 if (cookieHeader != null && !cookieHeader.isEmpty()) {
651 List<HttpCookie> cookies = HttpCookie.parse(cookieHeader);
652 for (HttpCookie cookie : cookies) {
653 cookieManager.getCookieStore().add(uri, cookie);
658 if (key.equalsIgnoreCase("Location")) {
659 // get redirect location
660 location = header.getValue().get(0);
661 if (location != null && !location.isEmpty()) {
662 location = uri.resolve(location).toString();
664 if (location.toLowerCase().startsWith("http://")) {
666 location = "https://" + location.substring(7);
667 logger.debug("Redirect corrected to {}", location);
674 logger.debug("Call to {} succeeded", url);
676 } else if (code == 302 && location != null) {
677 logger.debug("Redirected to {}", location);
679 if (redirectCounter > 30) {
680 throw new ConnectionException("Too many redirects");
682 currentUrl = location;
684 continue; // repeat with new location
688 logger.debug("Retry call to {}", url);
690 if (retryCounter > badRequestRepeats) {
691 throw new HttpException(code,
692 verb + " url '" + url + "' failed: " + connection.getResponseMessage());
696 } catch (InterruptedException e) {
697 logger.warn("Unable to wait for next call to {}", url, e);
700 } catch (IOException e) {
701 if (connection != null) {
702 connection.disconnect();
704 logger.warn("Request to url '{}' fails with unknown error", url, e);
706 } catch (Exception e) {
707 if (connection != null) {
708 connection.disconnect();
715 public String registerConnectionAsApp(String oAutRedirectUrl)
716 throws ConnectionException, IOException, URISyntaxException {
717 URI oAutRedirectUri = new URI(oAutRedirectUrl);
719 Map<String, String> queryParameters = new LinkedHashMap<>();
720 String query = oAutRedirectUri.getQuery();
721 String[] pairs = query.split("&");
722 for (String pair : pairs) {
723 int idx = pair.indexOf("=");
724 queryParameters.put(URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8.name()),
725 URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8.name()));
727 String accessToken = queryParameters.get("openid.oa2.access_token");
729 Map<String, String> cookieMap = new HashMap<>();
731 List<JsonWebSiteCookie> webSiteCookies = new ArrayList<>();
732 for (HttpCookie cookie : getSessionCookies("https://www.amazon.com")) {
733 cookieMap.put(cookie.getName(), cookie.getValue());
734 webSiteCookies.add(new JsonWebSiteCookie(cookie.getName(), cookie.getValue()));
737 JsonWebSiteCookie[] webSiteCookiesArray = new JsonWebSiteCookie[webSiteCookies.size()];
738 webSiteCookiesArray = webSiteCookies.toArray(webSiteCookiesArray);
740 JsonRegisterAppRequest registerAppRequest = new JsonRegisterAppRequest(serial, accessToken, frc,
741 webSiteCookiesArray);
742 String registerAppRequestJson = gson.toJson(registerAppRequest);
744 HashMap<String, String> registerHeaders = new HashMap<>();
745 registerHeaders.put("x-amzn-identity-auth-domain", "api.amazon.com");
747 String registerAppResultJson = makeRequestAndReturnString("POST", "https://api.amazon.com/auth/register",
748 registerAppRequestJson, true, registerHeaders);
749 JsonRegisterAppResponse registerAppResponse = parseJson(registerAppResultJson, JsonRegisterAppResponse.class);
751 if (registerAppResponse == null) {
752 throw new ConnectionException("Error: No response receivec from register application");
754 Response response = registerAppResponse.response;
755 if (response == null) {
756 throw new ConnectionException("Error: No response received from register application");
758 Success success = response.success;
759 if (success == null) {
760 throw new ConnectionException("Error: No success received from register application");
762 Tokens tokens = success.tokens;
763 if (tokens == null) {
764 throw new ConnectionException("Error: No tokens received from register application");
766 Bearer bearer = tokens.bearer;
767 if (bearer == null) {
768 throw new ConnectionException("Error: No bearer received from register application");
770 this.refreshToken = bearer.refreshToken;
771 if (this.refreshToken == null || this.refreshToken.isEmpty()) {
772 throw new ConnectionException("Error: No refresh token received");
776 // Check which is the owner domain
777 String usersMeResponseJson = makeRequestAndReturnString("GET",
778 "https://alexa.amazon.com/api/users/me?platform=ios&version=2.2.223830.0", null, false, null);
779 JsonUsersMeResponse usersMeResponse = parseJson(usersMeResponseJson, JsonUsersMeResponse.class);
780 if (usersMeResponse == null) {
781 throw new IllegalArgumentException("Received no response on me-request");
783 URI uri = new URI(usersMeResponse.marketPlaceDomainName);
784 String host = uri.getHost();
786 // Switch to owner domain
790 } catch (Exception e) {
794 String deviceName = null;
795 Extensions extensions = success.extensions;
796 if (extensions != null) {
797 DeviceInfo deviceInfo = extensions.deviceInfo;
798 if (deviceInfo != null) {
799 deviceName = deviceInfo.deviceName;
802 if (deviceName == null) {
803 deviceName = "Unknown";
805 this.deviceName = deviceName;
809 private void exchangeToken() throws IOException, URISyntaxException {
811 String cookiesJson = "{\"cookies\":{\"." + getAmazonSite() + "\":[]}}";
812 String cookiesBase64 = Base64.getEncoder().encodeToString(cookiesJson.getBytes());
814 String exchangePostData = "di.os.name=iOS&app_version=2.2.223830.0&domain=." + getAmazonSite()
815 + "&source_token=" + URLEncoder.encode(this.refreshToken, "UTF8")
816 + "&requested_token_type=auth_cookies&source_token_type=refresh_token&di.hw.version=iPhone&di.sdk.version=6.10.0&cookies="
817 + cookiesBase64 + "&app_name=Amazon%20Alexa&di.os.version=11.4.1";
819 HashMap<String, String> exchangeTokenHeader = new HashMap<>();
820 exchangeTokenHeader.put("Cookie", "");
822 String exchangeTokenJson = makeRequestAndReturnString("POST",
823 "https://www." + getAmazonSite() + "/ap/exchangetoken", exchangePostData, false, exchangeTokenHeader);
824 JsonExchangeTokenResponse exchangeTokenResponse = gson.fromJson(exchangeTokenJson,
825 JsonExchangeTokenResponse.class);
827 org.openhab.binding.amazonechocontrol.internal.jsons.JsonExchangeTokenResponse.Response response = exchangeTokenResponse.response;
828 if (response != null) {
829 org.openhab.binding.amazonechocontrol.internal.jsons.JsonExchangeTokenResponse.Tokens tokens = response.tokens;
830 if (tokens != null) {
831 Map<String, Cookie[]> cookiesMap = tokens.cookies;
832 if (cookiesMap != null) {
833 for (String domain : cookiesMap.keySet()) {
834 Cookie[] cookies = cookiesMap.get(domain);
835 for (Cookie cookie : cookies) {
836 if (cookie != null) {
837 HttpCookie httpCookie = new HttpCookie(cookie.name, cookie.value);
838 httpCookie.setPath(cookie.path);
839 httpCookie.setDomain(domain);
840 Boolean secure = cookie.secure;
841 if (secure != null) {
842 httpCookie.setSecure(secure);
844 this.cookieManager.getCookieStore().add(null, httpCookie);
851 if (!verifyLogin()) {
852 throw new ConnectionException("Verify login failed after token exchange");
854 this.renewTime = (long) (System.currentTimeMillis() + Connection.EXPIRES_IN * 1000d / 0.8d); // start renew at
857 public boolean checkRenewSession() throws URISyntaxException, IOException {
858 if (System.currentTimeMillis() >= this.renewTime) {
859 String renewTokenPostData = "app_name=Amazon%20Alexa&app_version=2.2.223830.0&di.sdk.version=6.10.0&source_token="
860 + URLEncoder.encode(refreshToken, StandardCharsets.UTF_8.name())
861 + "&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";
862 String renewTokenResponseJson = makeRequestAndReturnString("POST", "https://api.amazon.com/auth/token",
863 renewTokenPostData, false, null);
864 parseJson(renewTokenResponseJson, JsonRenewTokenResponse.class);
872 public boolean getIsLoggedIn() {
873 return loginTime != null;
876 public String getLoginPage() throws IOException, URISyntaxException {
877 // clear session data
880 logger.debug("Start Login to {}", alexaServer);
882 if (!checkDeviceIdIsValid(deviceId)) {
883 deviceId = generateDeviceId();
884 logger.debug("Generating new device id (old device id had invalid format).");
887 String mapMdJson = "{\"device_user_dictionary\":[],\"device_registration_data\":{\"software_version\":\"1\"},\"app_identifier\":{\"app_version\":\"2.2.223830\",\"bundle_id\":\"com.amazon.echo\"}}";
888 String mapMdCookie = Base64.getEncoder().encodeToString(mapMdJson.getBytes());
890 cookieManager.getCookieStore().add(new URI("https://www.amazon.com"), new HttpCookie("map-md", mapMdCookie));
891 cookieManager.getCookieStore().add(new URI("https://www.amazon.com"), new HttpCookie("frc", frc));
893 Map<String, String> customHeaders = new HashMap<>();
894 customHeaders.put("authority", "www.amazon.com");
895 String loginFormHtml = makeRequestAndReturnString("GET", "https://www.amazon.com"
896 + "/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:"
898 + "&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",
899 null, false, customHeaders);
901 logger.debug("Received login form {}", loginFormHtml);
902 return loginFormHtml;
905 public boolean verifyLogin() throws IOException, URISyntaxException {
906 if (this.refreshToken == null) {
909 Authentication authentication = tryGetBootstrap();
910 if (authentication != null && authentication.authenticated) {
911 verifyTime = new Date();
912 if (loginTime == null) {
913 loginTime = verifyTime;
920 public List<HttpCookie> getSessionCookies() {
922 return cookieManager.getCookieStore().get(new URI(alexaServer));
923 } catch (URISyntaxException e) {
924 return new ArrayList<>();
928 public List<HttpCookie> getSessionCookies(String server) {
930 return cookieManager.getCookieStore().get(new URI(server));
931 } catch (URISyntaxException e) {
932 return new ArrayList<>();
936 public void logout() {
937 cookieManager.getCookieStore().removeAll();
944 if (announcementTimer != null) {
945 announcements.clear();
946 announcementTimer.cancel(true);
948 if (textToSpeechTimer != null) {
949 textToSpeeches.clear();
950 textToSpeechTimer.cancel(true);
952 if (volumeTimer != null) {
954 volumeTimer.cancel(true);
956 singles.values().forEach(queueObject -> queueObject.dispose());
957 groups.values().forEach(queueObject -> queueObject.dispose());
958 if (singleGroupTimer != null) {
959 singleGroupTimer.cancel(true);
964 private <T> @Nullable T parseJson(String json, Class<T> type) throws JsonSyntaxException, IllegalStateException {
966 return gson.fromJson(json, type);
967 } catch (JsonParseException | IllegalStateException e) {
968 logger.warn("Parsing json failed", e);
969 logger.warn("Illegal json: {}", json);
974 // commands and states
975 public WakeWord[] getWakeWords() {
978 json = makeRequestAndReturnString(alexaServer + "/api/wake-word?cached=true");
979 JsonWakeWords wakeWords = parseJson(json, JsonWakeWords.class);
980 if (wakeWords != null) {
981 WakeWord[] result = wakeWords.wakeWords;
982 if (result != null) {
986 } catch (IOException | URISyntaxException e) {
987 logger.info("getting wakewords failed", e);
989 return new WakeWord[0];
992 public List<SmartHomeBaseDevice> getSmarthomeDeviceList() throws IOException, URISyntaxException {
994 String json = makeRequestAndReturnString(alexaServer + "/api/phoenix");
995 logger.debug("getSmartHomeDevices result: {}", json);
997 JsonNetworkDetails networkDetails = parseJson(json, JsonNetworkDetails.class);
998 if (networkDetails == null) {
999 throw new IllegalArgumentException("received no response on network detail request");
1001 Object jsonObject = gson.fromJson(networkDetails.networkDetail, Object.class);
1002 List<SmartHomeBaseDevice> result = new ArrayList<>();
1003 searchSmartHomeDevicesRecursive(jsonObject, result);
1006 } catch (Exception e) {
1007 logger.warn("getSmartHomeDevices fails: {}", e.getMessage());
1012 private void searchSmartHomeDevicesRecursive(@Nullable Object jsonNode, List<SmartHomeBaseDevice> devices) {
1013 if (jsonNode instanceof Map) {
1014 @SuppressWarnings("rawtypes")
1015 Map map = (Map) jsonNode;
1016 if (map.containsKey("entityId") && map.containsKey("friendlyName") && map.containsKey("actions")) {
1017 // device node found, create type element and add it to the results
1018 JsonElement element = gson.toJsonTree(jsonNode);
1019 SmartHomeDevice shd = parseJson(element.toString(), SmartHomeDevice.class);
1023 } else if (map.containsKey("applianceGroupName")) {
1024 JsonElement element = gson.toJsonTree(jsonNode);
1025 SmartHomeGroup shg = parseJson(element.toString(), SmartHomeGroup.class);
1030 map.values().forEach(value -> searchSmartHomeDevicesRecursive(value, devices));
1035 public List<Device> getDeviceList() throws IOException, URISyntaxException {
1036 String json = getDeviceListJson();
1037 JsonDevices devices = parseJson(json, JsonDevices.class);
1038 if (devices != null) {
1039 Device[] result = devices.devices;
1040 if (result != null) {
1041 return new ArrayList<>(Arrays.asList(result));
1044 return Collections.emptyList();
1047 public String getDeviceListJson() throws IOException, URISyntaxException {
1048 String json = makeRequestAndReturnString(alexaServer + "/api/devices-v2/device?cached=false");
1052 public Map<String, JsonArray> getSmartHomeDeviceStatesJson(Set<String> applianceIds)
1053 throws IOException, URISyntaxException {
1054 JsonObject requestObject = new JsonObject();
1055 JsonArray stateRequests = new JsonArray();
1056 for (String applianceId : applianceIds) {
1057 JsonObject stateRequest = new JsonObject();
1058 stateRequest.addProperty("entityId", applianceId);
1059 stateRequest.addProperty("entityType", "APPLIANCE");
1060 stateRequests.add(stateRequest);
1062 requestObject.add("stateRequests", stateRequests);
1063 String requestBody = requestObject.toString();
1064 String json = makeRequestAndReturnString("POST", alexaServer + "/api/phoenix/state", requestBody, true, null);
1065 logger.trace("Requested {} and received {}", requestBody, json);
1067 JsonObject responseObject = this.gson.fromJson(json, JsonObject.class);
1068 JsonArray deviceStates = (JsonArray) responseObject.get("deviceStates");
1069 Map<String, JsonArray> result = new HashMap<>();
1070 for (JsonElement deviceState : deviceStates) {
1071 JsonObject deviceStateObject = deviceState.getAsJsonObject();
1072 JsonObject entity = deviceStateObject.get("entity").getAsJsonObject();
1073 String applicanceId = entity.get("entityId").getAsString();
1074 JsonElement capabilityState = deviceStateObject.get("capabilityStates");
1075 if (capabilityState != null && capabilityState.isJsonArray()) {
1076 result.put(applicanceId, capabilityState.getAsJsonArray());
1082 public @Nullable JsonPlayerState getPlayer(Device device) throws IOException, URISyntaxException {
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) throws IOException, URISyntaxException {
1090 String json = makeRequestAndReturnString(alexaServer + "/api/media/state?deviceSerialNumber="
1091 + device.serialNumber + "&deviceType=" + device.deviceType);
1092 JsonMediaState mediaState = parseJson(json, JsonMediaState.class);
1096 public Activity[] getActivities(int number, @Nullable Long startTime) {
1099 json = makeRequestAndReturnString(alexaServer + "/api/activities?startTime="
1100 + (startTime != null ? startTime : "") + "&size=" + number + "&offset=1");
1101 JsonActivities activities = parseJson(json, JsonActivities.class);
1102 if (activities != null) {
1103 Activity[] activiesArray = activities.activities;
1104 if (activiesArray != null) {
1105 return activiesArray;
1108 } catch (IOException | URISyntaxException e) {
1109 logger.info("getting activities failed", e);
1111 return new Activity[0];
1114 public @Nullable JsonBluetoothStates getBluetoothConnectionStates() {
1117 json = makeRequestAndReturnString(alexaServer + "/api/bluetooth?cached=true");
1118 } catch (IOException | URISyntaxException e) {
1119 logger.debug("failed to get bluetooth state: {}", e.getMessage());
1120 return new JsonBluetoothStates();
1122 JsonBluetoothStates bluetoothStates = parseJson(json, JsonBluetoothStates.class);
1123 return bluetoothStates;
1126 public @Nullable JsonPlaylists getPlaylists(Device device) throws IOException, URISyntaxException {
1127 String json = makeRequestAndReturnString(alexaServer + "/api/cloudplayer/playlists?deviceSerialNumber="
1128 + device.serialNumber + "&deviceType=" + device.deviceType + "&mediaOwnerCustomerId="
1129 + (this.accountCustomerId == null || this.accountCustomerId.isEmpty() ? device.deviceOwnerCustomerId
1130 : this.accountCustomerId));
1131 JsonPlaylists playlists = parseJson(json, JsonPlaylists.class);
1135 public void command(Device device, String command) throws IOException, URISyntaxException {
1136 String url = alexaServer + "/api/np/command?deviceSerialNumber=" + device.serialNumber + "&deviceType="
1137 + device.deviceType;
1138 makeRequest("POST", url, command, true, true, null, 0);
1141 public void smartHomeCommand(String entityId, String action) throws IOException {
1142 smartHomeCommand(entityId, action, null, null);
1145 public void smartHomeCommand(String entityId, String action, @Nullable String property, @Nullable Object value)
1146 throws IOException {
1147 String url = alexaServer + "/api/phoenix/state";
1149 JsonObject json = new JsonObject();
1150 JsonArray controlRequests = new JsonArray();
1151 JsonObject controlRequest = new JsonObject();
1152 controlRequest.addProperty("entityId", entityId);
1153 controlRequest.addProperty("entityType", "APPLIANCE");
1154 JsonObject parameters = new JsonObject();
1155 parameters.addProperty("action", action);
1156 if (property != null) {
1157 if (value instanceof Boolean) {
1158 parameters.addProperty(property, (boolean) value);
1159 } else if (value instanceof String) {
1160 parameters.addProperty(property, (String) value);
1161 } else if (value instanceof Number) {
1162 parameters.addProperty(property, (Number) value);
1163 } else if (value instanceof Character) {
1164 parameters.addProperty(property, (Character) value);
1165 } else if (value instanceof JsonElement) {
1166 parameters.add(property, (JsonElement) value);
1169 controlRequest.add("parameters", parameters);
1170 controlRequests.add(controlRequest);
1171 json.add("controlRequests", controlRequests);
1173 String requestBody = json.toString();
1175 String resultBody = makeRequestAndReturnString("PUT", url, requestBody, true, null);
1176 logger.debug("{}", resultBody);
1177 JsonObject result = parseJson(resultBody, JsonObject.class);
1178 if (result != null) {
1179 JsonElement errors = result.get("errors");
1180 if (errors != null && errors.isJsonArray()) {
1181 JsonArray errorList = errors.getAsJsonArray();
1182 if (errorList.size() > 0) {
1183 logger.info("Smart home device command failed.");
1184 logger.info("Request:");
1185 logger.info("{}", requestBody);
1186 logger.info("Answer:");
1187 for (JsonElement error : errorList) {
1188 logger.info("{}", error.toString());
1193 } catch (URISyntaxException e) {
1194 logger.info("Wrong url {}", url, e);
1198 public void notificationVolume(Device device, int volume) throws IOException, URISyntaxException {
1199 String url = alexaServer + "/api/device-notification-state/" + device.deviceType + "/" + device.softwareVersion
1200 + "/" + device.serialNumber;
1201 String command = "{\"deviceSerialNumber\":\"" + device.serialNumber + "\",\"deviceType\":\"" + device.deviceType
1202 + "\",\"softwareVersion\":\"" + device.softwareVersion + "\",\"volumeLevel\":" + volume + "}";
1203 makeRequest("PUT", url, command, true, true, null, 0);
1206 public void ascendingAlarm(Device device, boolean ascendingAlarm) throws IOException, URISyntaxException {
1207 String url = alexaServer + "/api/ascending-alarm/" + device.serialNumber;
1208 String command = "{\"ascendingAlarmEnabled\":" + (ascendingAlarm ? "true" : "false")
1209 + ",\"deviceSerialNumber\":\"" + device.serialNumber + "\",\"deviceType\":\"" + device.deviceType
1210 + "\",\"deviceAccountId\":null}";
1211 makeRequest("PUT", url, command, true, true, null, 0);
1214 public DeviceNotificationState[] getDeviceNotificationStates() {
1217 json = makeRequestAndReturnString(alexaServer + "/api/device-notification-state");
1218 JsonDeviceNotificationState result = parseJson(json, JsonDeviceNotificationState.class);
1219 if (result != null) {
1220 DeviceNotificationState[] deviceNotificationStates = result.deviceNotificationStates;
1221 if (deviceNotificationStates != null) {
1222 return deviceNotificationStates;
1225 } catch (IOException | URISyntaxException e) {
1226 logger.info("Error getting device notification states", e);
1228 return new DeviceNotificationState[0];
1231 public AscendingAlarmModel[] getAscendingAlarm() {
1234 json = makeRequestAndReturnString(alexaServer + "/api/ascending-alarm");
1235 JsonAscendingAlarm result = parseJson(json, JsonAscendingAlarm.class);
1236 if (result != null) {
1237 AscendingAlarmModel[] ascendingAlarmModelList = result.ascendingAlarmModelList;
1238 if (ascendingAlarmModelList != null) {
1239 return ascendingAlarmModelList;
1242 } catch (IOException | URISyntaxException e) {
1243 logger.info("Error getting device notification states", e);
1245 return new AscendingAlarmModel[0];
1248 public void bluetooth(Device device, @Nullable String address) throws IOException, URISyntaxException {
1249 if (address == null || address.isEmpty()) {
1252 alexaServer + "/api/bluetooth/disconnect-sink/" + device.deviceType + "/" + device.serialNumber, "",
1253 true, true, null, 0);
1256 alexaServer + "/api/bluetooth/pair-sink/" + device.deviceType + "/" + device.serialNumber,
1257 "{\"bluetoothDeviceAddress\":\"" + address + "\"}", true, true, null, 0);
1261 public void playRadio(Device device, @Nullable String stationId) throws IOException, URISyntaxException {
1262 if (stationId == null || stationId.isEmpty()) {
1263 command(device, "{\"type\":\"PauseCommand\"}");
1266 alexaServer + "/api/tunein/queue-and-play?deviceSerialNumber=" + device.serialNumber
1267 + "&deviceType=" + device.deviceType + "&guideId=" + stationId
1268 + "&contentType=station&callSign=&mediaOwnerCustomerId="
1269 + (this.accountCustomerId == null || this.accountCustomerId.isEmpty()
1270 ? device.deviceOwnerCustomerId
1271 : this.accountCustomerId),
1272 "", true, true, null, 0);
1276 public void playAmazonMusicTrack(Device device, @Nullable String trackId) throws IOException, URISyntaxException {
1277 if (trackId == null || trackId.isEmpty()) {
1278 command(device, "{\"type\":\"PauseCommand\"}");
1280 String command = "{\"trackId\":\"" + trackId + "\",\"playQueuePrime\":true}";
1282 alexaServer + "/api/cloudplayer/queue-and-play?deviceSerialNumber=" + device.serialNumber
1283 + "&deviceType=" + device.deviceType + "&mediaOwnerCustomerId="
1284 + (this.accountCustomerId == null || this.accountCustomerId.isEmpty()
1285 ? device.deviceOwnerCustomerId
1286 : this.accountCustomerId)
1288 command, true, true, null, 0);
1292 public void playAmazonMusicPlayList(Device device, @Nullable String playListId)
1293 throws IOException, URISyntaxException {
1294 if (playListId == null || playListId.isEmpty()) {
1295 command(device, "{\"type\":\"PauseCommand\"}");
1297 String command = "{\"playlistId\":\"" + playListId + "\",\"playQueuePrime\":true}";
1299 alexaServer + "/api/cloudplayer/queue-and-play?deviceSerialNumber=" + device.serialNumber
1300 + "&deviceType=" + device.deviceType + "&mediaOwnerCustomerId="
1301 + (this.accountCustomerId == null || this.accountCustomerId.isEmpty()
1302 ? device.deviceOwnerCustomerId
1303 : this.accountCustomerId)
1305 command, true, true, null, 0);
1309 public void sendNotificationToMobileApp(String customerId, String text, @Nullable String title)
1310 throws IOException, URISyntaxException {
1311 Map<String, Object> parameters = new HashMap<>();
1312 parameters.put("notificationMessage", text);
1313 parameters.put("alexaUrl", "#v2/behaviors");
1314 if (title != null && !title.isEmpty()) {
1315 parameters.put("title", title);
1317 parameters.put("title", "OpenHAB");
1319 parameters.put("customerId", customerId);
1320 executeSequenceCommand(null, "Alexa.Notifications.SendMobilePush", parameters);
1323 public synchronized void announcement(Device device, String speak, String bodyText, @Nullable String title,
1324 @Nullable Integer ttsVolume, @Nullable Integer standardVolume) {
1325 if (speak == null || speak.replaceAll("<.+?>", " ").replaceAll("\\s+", " ").trim().isEmpty()) {
1328 if (announcementTimer != null) {
1329 announcementTimer.cancel(true);
1330 announcementTimer = null;
1332 Announcement announcement = announcements.computeIfAbsent(Objects.hash(speak, bodyText, title),
1333 k -> new Announcement(speak, bodyText, title));
1334 announcement.devices.add(device);
1335 announcement.ttsVolumes.add(ttsVolume);
1336 announcement.standardVolumes.add(standardVolume);
1337 announcementTimer = scheduler.schedule(this::sendAnnouncement, 500, TimeUnit.MILLISECONDS);
1340 private synchronized void sendAnnouncement() {
1341 // NECESSARY TO CANCEL AND NULL TIMER?
1342 if (announcementTimer != null) {
1343 announcementTimer.cancel(true);
1344 announcementTimer = null;
1346 Iterator<Announcement> iterator = announcements.values().iterator();
1347 while (iterator.hasNext()) {
1348 Announcement announcement = iterator.next();
1349 if (announcement != null) {
1351 List<Device> devices = announcement.devices;
1352 if (devices != null && !devices.isEmpty()) {
1353 String speak = announcement.speak;
1354 String bodyText = announcement.bodyText;
1355 String title = announcement.title;
1356 List<@Nullable Integer> ttsVolumes = announcement.ttsVolumes;
1357 List<@Nullable Integer> standardVolumes = announcement.standardVolumes;
1359 Map<String, Object> parameters = new HashMap<>();
1360 parameters.put("expireAfter", "PT5S");
1361 JsonAnnouncementContent[] contentArray = new JsonAnnouncementContent[1];
1362 JsonAnnouncementContent content = new JsonAnnouncementContent();
1363 content.display.title = title == null || title.isEmpty() ? "openHAB" : title;
1364 content.display.body = bodyText;
1365 content.display.body = speak.replaceAll("<.+?>", " ").replaceAll("\\s+", " ").trim();
1366 if (speak.startsWith("<speak>") && speak.endsWith("</speak>")) {
1367 content.speak.type = "ssml";
1369 content.speak.value = speak;
1371 contentArray[0] = content;
1373 parameters.put("content", contentArray);
1375 JsonAnnouncementTarget target = new JsonAnnouncementTarget();
1376 target.customerId = devices.get(0).deviceOwnerCustomerId;
1377 TargetDevice[] targetDevices = devices.stream().map(TargetDevice::new)
1378 .collect(Collectors.toList()).toArray(new TargetDevice[0]);
1379 target.devices = targetDevices;
1380 parameters.put("target", target);
1382 String accountCustomerId = this.accountCustomerId;
1383 String customerId = accountCustomerId == null || accountCustomerId.isEmpty()
1384 ? devices.toArray(new Device[0])[0].deviceOwnerCustomerId
1385 : accountCustomerId;
1387 if (customerId != null) {
1388 parameters.put("customerId", customerId);
1390 executeSequenceCommandWithVolume(devices.toArray(new Device[0]), "AlexaAnnouncement",
1391 parameters, ttsVolumes.toArray(new Integer[0]),
1392 standardVolumes.toArray(new Integer[0]));
1394 } catch (Exception e) {
1395 logger.warn("send announcement fails with unexpected error", e);
1402 public synchronized void textToSpeech(Device device, String text, @Nullable Integer ttsVolume,
1403 @Nullable Integer standardVolume) {
1404 if (text == null || text.replaceAll("<.+?>", "").replaceAll("\\s+", " ").trim().isEmpty()) {
1407 if (textToSpeechTimer != null) {
1408 textToSpeechTimer.cancel(true);
1409 textToSpeechTimer = null;
1411 TextToSpeech textToSpeech = textToSpeeches.computeIfAbsent(Objects.hash(text), k -> new TextToSpeech(text));
1412 textToSpeech.devices.add(device);
1413 textToSpeech.ttsVolumes.add(ttsVolume);
1414 textToSpeech.standardVolumes.add(standardVolume);
1415 textToSpeechTimer = scheduler.schedule(this::sendTextToSpeech, 500, TimeUnit.MILLISECONDS);
1418 private synchronized void sendTextToSpeech() {
1419 // NECESSARY TO CANCEL AND NULL TIMER?
1420 if (textToSpeechTimer != null) {
1421 textToSpeechTimer.cancel(true);
1422 textToSpeechTimer = null;
1424 Iterator<TextToSpeech> iterator = textToSpeeches.values().iterator();
1425 while (iterator.hasNext()) {
1426 TextToSpeech textToSpeech = iterator.next();
1427 if (textToSpeech != null) {
1429 List<Device> devices = textToSpeech.devices;
1430 if (devices != null && !devices.isEmpty()) {
1431 String text = textToSpeech.text;
1432 List<@Nullable Integer> ttsVolumes = textToSpeech.ttsVolumes;
1433 List<@Nullable Integer> standardVolumes = textToSpeech.standardVolumes;
1435 Map<String, Object> parameters = new HashMap<>();
1436 parameters.put("textToSpeak", text);
1437 executeSequenceCommandWithVolume(devices.toArray(new Device[0]), "Alexa.Speak", parameters,
1438 ttsVolumes.toArray(new Integer[0]), standardVolumes.toArray(new Integer[0]));
1440 } catch (Exception e) {
1441 logger.warn("send textToSpeech fails with unexpected error", e);
1448 public synchronized void volume(Device device, int vol) {
1449 if (volumeTimer != null) {
1450 volumeTimer.cancel(true);
1453 Volume volume = volumes.computeIfAbsent(vol, k -> new Volume(vol));
1454 volume.devices.add(device);
1455 volume.volumes.add(vol);
1456 volumeTimer = scheduler.schedule(this::sendVolume, 500, TimeUnit.MILLISECONDS);
1459 private synchronized void sendVolume() {
1460 // NECESSARY TO CANCEL AND NULL TIMER?
1461 if (volumeTimer != null) {
1462 volumeTimer.cancel(true);
1465 Iterator<Volume> iterator = volumes.values().iterator();
1466 while (iterator.hasNext()) {
1467 Volume volume = iterator.next();
1468 if (volume != null) {
1470 List<Device> devices = volume.devices;
1471 if (devices != null && !devices.isEmpty()) {
1472 List<@Nullable Integer> volumes = volume.volumes;
1474 executeSequenceCommandWithVolume(devices.toArray(new Device[0]), null, null,
1475 volumes.toArray(new Integer[0]), null);
1477 } catch (Exception e) {
1478 logger.warn("send volume fails with unexpected error", e);
1485 private void executeSequenceCommandWithVolume(@Nullable Device[] devices, @Nullable String command,
1486 @Nullable Map<String, Object> parameters, @NonNull Integer[] ttsVolumes,
1487 @Nullable Integer @Nullable [] standardVolumes) throws IOException, URISyntaxException {
1488 JsonArray serialNodesToExecute = new JsonArray();
1489 if (ttsVolumes != null) {
1490 JsonArray ttsVolumeNodesToExecute = new JsonArray();
1491 for (int i = 0; i < devices.length; i++) {
1492 if (ttsVolumes[i] != null && (standardVolumes == null || !ttsVolumes[i].equals(standardVolumes[i]))) {
1493 Map<String, @Nullable Object> volumeParameters = new HashMap<>();
1494 volumeParameters.put("value", ttsVolumes[i]);
1495 ttsVolumeNodesToExecute
1496 .add(createExecutionNode(devices[i], "Alexa.DeviceControls.Volume", volumeParameters));
1499 if (ttsVolumeNodesToExecute.size() > 0) {
1500 // executeSequenceNodes(devices, ttsVolumeNodesToExecute, true);
1501 JsonObject parallelNodesToExecute = new JsonObject();
1502 parallelNodesToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1503 parallelNodesToExecute.add("nodesToExecute", ttsVolumeNodesToExecute);
1504 serialNodesToExecute.add(parallelNodesToExecute);
1508 if (command != null && parameters != null) {
1509 JsonArray commandNodesToExecute = new JsonArray();
1510 if ("Alexa.Speak".equals(command)) {
1511 for (Device device : devices) {
1512 commandNodesToExecute.add(createExecutionNode(device, command, parameters));
1515 commandNodesToExecute.add(createExecutionNode(devices[0], command, parameters));
1517 if (commandNodesToExecute.size() > 0) {
1518 // executeSequenceNodes(devices, nodesToExecute, true);
1519 JsonObject parallelNodesToExecute = new JsonObject();
1520 parallelNodesToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1521 parallelNodesToExecute.add("nodesToExecute", commandNodesToExecute);
1522 serialNodesToExecute.add(parallelNodesToExecute);
1526 if (serialNodesToExecute.size() > 0) {
1527 executeSequenceNodes(devices, serialNodesToExecute, false);
1530 if (standardVolumes != null) {
1531 JsonArray standardVolumeNodesToExecute = new JsonArray();
1532 for (int i = 0; i < devices.length; i++) {
1533 if (ttsVolumes[i] != null && standardVolumes[i] != null && !ttsVolumes[i].equals(standardVolumes[i])) {
1534 Map<String, @Nullable Object> volumeParameters = new HashMap<>();
1535 volumeParameters.put("value", standardVolumes[i]);
1536 standardVolumeNodesToExecute
1537 .add(createExecutionNode(devices[i], "Alexa.DeviceControls.Volume", volumeParameters));
1540 if (standardVolumeNodesToExecute.size() > 0) {
1541 executeSequenceNodes(devices, standardVolumeNodesToExecute, true);
1546 // commands: Alexa.Weather.Play, Alexa.Traffic.Play, Alexa.FlashBriefing.Play,
1547 // Alexa.GoodMorning.Play,
1548 // Alexa.SingASong.Play, Alexa.TellStory.Play, Alexa.Speak (textToSpeach)
1549 public void executeSequenceCommand(@Nullable Device device, String command,
1550 @Nullable Map<String, Object> parameters) throws IOException, URISyntaxException {
1551 JsonObject nodeToExecute = createExecutionNode(device, command, parameters);
1552 executeSequenceNode(new Device[] { device }, nodeToExecute);
1555 private void executeSequenceNode(Device[] devices, JsonObject nodeToExecute) {
1556 if (devices.length == 1 && groups.values().stream().anyMatch(queueObject -> queueObject.queueRunning.get())
1557 || devices.length > 1
1558 && singles.values().stream().anyMatch(queueObject -> queueObject.queueRunning.get())) {
1559 if (singleGroupTimer != null) {
1560 singleGroupTimer.cancel(true);
1561 singleGroupTimer = null;
1563 singleGroupTimer = scheduler.schedule(() -> executeSequenceNode(devices, nodeToExecute), 500,
1564 TimeUnit.MILLISECONDS);
1569 if (devices.length == 1) {
1570 if (!singles.containsKey(devices[0])) {
1571 singles.put(devices[0], new QueueObject());
1573 singles.get(devices[0]).queue.add(nodeToExecute);
1575 if (!groups.containsKey(devices[0])) {
1576 groups.put(devices[0], new QueueObject());
1578 groups.get(devices[0]).queue.add(nodeToExecute);
1581 if (devices.length == 1 && singles.get(devices[0]).queueRunning.compareAndSet(false, true)) {
1582 queuedExecuteSequenceNode(devices[0], true);
1583 } else if (devices.length > 1 && groups.get(devices[0]).queueRunning.compareAndSet(false, true)) {
1584 queuedExecuteSequenceNode(devices[0], false);
1588 private void queuedExecuteSequenceNode(Device device, boolean single) {
1589 QueueObject queueObject = single ? singles.get(device) : groups.get(device);
1590 JsonObject nodeToExecute = queueObject.queue.poll();
1591 if (nodeToExecute != null) {
1592 ExecutionNodeObject executionNodeObject = getExecutionNodeObject(nodeToExecute);
1593 List<String> types = executionNodeObject.types;
1595 if (types.contains("Alexa.DeviceControls.Volume")) {
1598 if (types.contains("Announcement")) {
1604 JsonObject sequenceJson = new JsonObject();
1605 sequenceJson.addProperty("@type", "com.amazon.alexa.behaviors.model.Sequence");
1606 sequenceJson.add("startNode", nodeToExecute);
1608 JsonStartRoutineRequest request = new JsonStartRoutineRequest();
1609 request.sequenceJson = gson.toJson(sequenceJson);
1610 String json = gson.toJson(request);
1612 Map<String, String> headers = new HashMap<>();
1613 headers.put("Routines-Version", "1.1.218665");
1615 String text = executionNodeObject.text;
1616 if (text != null && !text.isEmpty()) {
1617 text = text.replaceAll("<.+?>", " ").replaceAll("\\s+", " ").trim();
1618 delay += text.length() * 150;
1621 makeRequest("POST", alexaServer + "/api/behaviors/preview", json, true, true, null, 3);
1622 } catch (IOException | URISyntaxException e) {
1623 logger.warn("execute sequence node fails with unexpected error", e);
1625 queueObject.senderUnblockFuture = scheduler.schedule(() -> queuedExecuteSequenceNode(device, single),
1626 delay, TimeUnit.MILLISECONDS);
1629 queueObject.dispose();
1630 // NECESSARY TO CANCEL AND NULL TIMER?
1631 if (!isSequenceNodeQueueRunning()) {
1632 if (singleGroupTimer != null) {
1633 singleGroupTimer.cancel(true);
1634 singleGroupTimer = null;
1640 private void executeSequenceNodes(Device[] devices, JsonArray nodesToExecute, boolean parallel)
1641 throws IOException, URISyntaxException {
1642 JsonObject serialNode = new JsonObject();
1644 serialNode.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1646 serialNode.addProperty("@type", "com.amazon.alexa.behaviors.model.SerialNode");
1649 serialNode.add("nodesToExecute", nodesToExecute);
1651 executeSequenceNode(devices, serialNode);
1654 private JsonObject createExecutionNode(@Nullable Device device, String command,
1655 @Nullable Map<String, Object> parameters) {
1656 JsonObject operationPayload = new JsonObject();
1657 if (device != null) {
1658 operationPayload.addProperty("deviceType", device.deviceType);
1659 operationPayload.addProperty("deviceSerialNumber", device.serialNumber);
1660 operationPayload.addProperty("locale", "");
1661 operationPayload.addProperty("customerId",
1662 this.accountCustomerId == null || this.accountCustomerId.isEmpty() ? device.deviceOwnerCustomerId
1663 : this.accountCustomerId);
1665 if (parameters != null) {
1666 for (String key : parameters.keySet()) {
1667 Object value = parameters.get(key);
1668 if (value instanceof String) {
1669 operationPayload.addProperty(key, (String) value);
1670 } else if (value instanceof Number) {
1671 operationPayload.addProperty(key, (Number) value);
1672 } else if (value instanceof Boolean) {
1673 operationPayload.addProperty(key, (Boolean) value);
1674 } else if (value instanceof Character) {
1675 operationPayload.addProperty(key, (Character) value);
1677 operationPayload.add(key, gson.toJsonTree(value));
1682 JsonObject nodeToExecute = new JsonObject();
1683 nodeToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode");
1684 nodeToExecute.addProperty("type", command);
1685 nodeToExecute.add("operationPayload", operationPayload);
1686 return nodeToExecute;
1690 private ExecutionNodeObject getExecutionNodeObject(JsonObject nodeToExecute) {
1691 ExecutionNodeObject executionNodeObject = new ExecutionNodeObject();
1692 if (nodeToExecute.has("nodesToExecute")) {
1693 JsonArray serialNodesToExecute = nodeToExecute.getAsJsonArray("nodesToExecute");
1694 if (serialNodesToExecute != null && serialNodesToExecute.size() > 0) {
1695 for (int i = 0; i < serialNodesToExecute.size(); i++) {
1696 JsonObject serialNodesToExecuteJsonObject = serialNodesToExecute.get(i).getAsJsonObject();
1697 if (serialNodesToExecuteJsonObject.has("nodesToExecute")) {
1698 JsonArray parallelNodesToExecute = serialNodesToExecuteJsonObject
1699 .getAsJsonArray("nodesToExecute");
1700 if (parallelNodesToExecute != null && parallelNodesToExecute.size() > 0) {
1701 JsonObject parallelNodesToExecuteJsonObject = parallelNodesToExecute.get(0)
1703 if (parallelNodesToExecuteJsonObject != null) {
1704 if (parallelNodesToExecuteJsonObject.has("type")) {
1705 executionNodeObject.types
1706 .add(parallelNodesToExecuteJsonObject.get("type").getAsString());
1707 if (parallelNodesToExecuteJsonObject.has("operationPayload")) {
1708 JsonObject operationPayload = parallelNodesToExecuteJsonObject
1709 .getAsJsonObject("operationPayload");
1710 if (operationPayload != null) {
1711 if (operationPayload.has("textToSpeak")) {
1712 executionNodeObject.text = operationPayload.get("textToSpeak")
1715 } else if (operationPayload.has("content")) {
1716 JsonArray content = operationPayload.getAsJsonArray("content");
1717 if (content != null && content.size() > 0) {
1718 JsonObject contentJsonObject = content.get(0).getAsJsonObject();
1719 if (contentJsonObject != null && contentJsonObject.has("speak")) {
1720 JsonObject speak = contentJsonObject.getAsJsonObject("speak");
1721 if (speak != null && speak.has("value")) {
1722 executionNodeObject.text = speak.get("value").getAsString();
1734 if (serialNodesToExecuteJsonObject.has("type")) {
1735 executionNodeObject.types.add(serialNodesToExecuteJsonObject.get("type").getAsString());
1736 if (serialNodesToExecuteJsonObject.has("operationPayload")) {
1737 JsonObject operationPayload = serialNodesToExecuteJsonObject
1738 .getAsJsonObject("operationPayload");
1739 if (operationPayload != null) {
1740 if (operationPayload.has("textToSpeak")) {
1741 executionNodeObject.text = operationPayload.get("textToSpeak").getAsString();
1743 } else if (operationPayload.has("content")) {
1744 JsonArray content = operationPayload.getAsJsonArray("content");
1745 if (content != null && content.size() > 0) {
1746 JsonObject contentJsonObject = content.get(0).getAsJsonObject();
1747 if (contentJsonObject != null && contentJsonObject.has("speak")) {
1748 JsonObject speak = contentJsonObject.getAsJsonObject("speak");
1749 if (speak != null && speak.has("value")) {
1750 executionNodeObject.text = speak.get("value").getAsString();
1764 return executionNodeObject;
1767 public void startRoutine(Device device, String utterance) throws IOException, URISyntaxException {
1768 JsonAutomation found = null;
1769 String deviceLocale = "";
1770 JsonAutomation[] routines = getRoutines();
1771 if (routines == null) {
1774 for (JsonAutomation routine : getRoutines()) {
1775 if (routine != null) {
1776 Trigger[] triggers = routine.triggers;
1777 if (triggers != null && routine.sequence != null) {
1778 for (JsonAutomation.Trigger trigger : triggers) {
1779 if (trigger == null) {
1782 Payload payload = trigger.payload;
1783 if (payload == null) {
1786 if (payload.utterance != null && payload.utterance.equalsIgnoreCase(utterance)) {
1788 deviceLocale = payload.locale;
1795 if (found != null) {
1796 String sequenceJson = gson.toJson(found.sequence);
1798 JsonStartRoutineRequest request = new JsonStartRoutineRequest();
1799 request.behaviorId = found.automationId;
1802 // "deviceType":"ALEXA_CURRENT_DEVICE_TYPE"
1803 String deviceType = "\"deviceType\":\"ALEXA_CURRENT_DEVICE_TYPE\"";
1804 String newDeviceType = "\"deviceType\":\"" + device.deviceType + "\"";
1805 sequenceJson = sequenceJson.replace(deviceType.subSequence(0, deviceType.length()),
1806 newDeviceType.subSequence(0, newDeviceType.length()));
1808 // "deviceSerialNumber":"ALEXA_CURRENT_DSN"
1809 String deviceSerial = "\"deviceSerialNumber\":\"ALEXA_CURRENT_DSN\"";
1810 String newDeviceSerial = "\"deviceSerialNumber\":\"" + device.serialNumber + "\"";
1811 sequenceJson = sequenceJson.replace(deviceSerial.subSequence(0, deviceSerial.length()),
1812 newDeviceSerial.subSequence(0, newDeviceSerial.length()));
1814 // "customerId": "ALEXA_CUSTOMER_ID"
1815 String customerId = "\"customerId\":\"ALEXA_CUSTOMER_ID\"";
1816 String newCustomerId = "\"customerId\":\""
1817 + (this.accountCustomerId == null || this.accountCustomerId.isEmpty() ? device.deviceOwnerCustomerId
1818 : this.accountCustomerId)
1820 sequenceJson = sequenceJson.replace(customerId.subSequence(0, customerId.length()),
1821 newCustomerId.subSequence(0, newCustomerId.length()));
1823 // "locale": "ALEXA_CURRENT_LOCALE"
1824 String locale = "\"locale\":\"ALEXA_CURRENT_LOCALE\"";
1825 String newlocale = deviceLocale != null && !deviceLocale.isEmpty() ? "\"locale\":\"" + deviceLocale + "\""
1826 : "\"locale\":null";
1827 sequenceJson = sequenceJson.replace(locale.subSequence(0, locale.length()),
1828 newlocale.subSequence(0, newlocale.length()));
1830 request.sequenceJson = sequenceJson;
1832 String requestJson = gson.toJson(request);
1833 makeRequest("POST", alexaServer + "/api/behaviors/preview", requestJson, true, true, null, 3);
1835 logger.warn("Routine {} not found", utterance);
1839 public @Nullable JsonAutomation @Nullable [] getRoutines() throws IOException, URISyntaxException {
1840 String json = makeRequestAndReturnString(alexaServer + "/api/behaviors/automations?limit=2000");
1841 JsonAutomation[] result = parseJson(json, JsonAutomation[].class);
1845 public JsonFeed[] getEnabledFlashBriefings() throws IOException, URISyntaxException {
1846 String json = makeRequestAndReturnString(alexaServer + "/api/content-skills/enabled-feeds");
1847 JsonEnabledFeeds result = parseJson(json, JsonEnabledFeeds.class);
1848 if (result == null) {
1849 return new JsonFeed[0];
1851 JsonFeed[] enabledFeeds = result.enabledFeeds;
1852 if (enabledFeeds != null) {
1853 return enabledFeeds;
1855 return new JsonFeed[0];
1858 public void setEnabledFlashBriefings(JsonFeed[] enabledFlashBriefing) throws IOException, URISyntaxException {
1859 JsonEnabledFeeds enabled = new JsonEnabledFeeds();
1860 enabled.enabledFeeds = enabledFlashBriefing;
1861 String json = gsonWithNullSerialization.toJson(enabled);
1862 makeRequest("POST", alexaServer + "/api/content-skills/enabled-feeds", json, true, true, null, 0);
1865 public JsonNotificationSound[] getNotificationSounds(Device device) throws IOException, URISyntaxException {
1866 String json = makeRequestAndReturnString(
1867 alexaServer + "/api/notification/sounds?deviceSerialNumber=" + device.serialNumber + "&deviceType="
1868 + device.deviceType + "&softwareVersion=" + device.softwareVersion);
1869 JsonNotificationSounds result = parseJson(json, JsonNotificationSounds.class);
1870 if (result == null) {
1871 return new JsonNotificationSound[0];
1873 JsonNotificationSound[] notificationSounds = result.notificationSounds;
1874 if (notificationSounds != null) {
1875 return notificationSounds;
1877 return new JsonNotificationSound[0];
1880 public JsonNotificationResponse[] notifications() throws IOException, URISyntaxException {
1881 String response = makeRequestAndReturnString(alexaServer + "/api/notifications");
1882 JsonNotificationsResponse result = parseJson(response, JsonNotificationsResponse.class);
1883 if (result == null) {
1884 return new JsonNotificationResponse[0];
1886 JsonNotificationResponse[] notifications = result.notifications;
1887 if (notifications == null) {
1888 return new JsonNotificationResponse[0];
1890 return notifications;
1893 public @Nullable JsonNotificationResponse notification(Device device, String type, @Nullable String label,
1894 @Nullable JsonNotificationSound sound) throws IOException, URISyntaxException {
1895 Date date = new Date(new Date().getTime());
1896 long createdDate = date.getTime();
1897 Date alarm = new Date(createdDate + 5000); // add 5 seconds, because amazon does not except calls for times in
1898 // the past (compared with the server time)
1899 long alarmTime = alarm.getTime();
1901 JsonNotificationRequest request = new JsonNotificationRequest();
1902 request.type = type;
1903 request.deviceSerialNumber = device.serialNumber;
1904 request.deviceType = device.deviceType;
1905 request.createdDate = createdDate;
1906 request.alarmTime = alarmTime;
1907 request.reminderLabel = label;
1908 request.sound = sound;
1909 request.originalDate = new SimpleDateFormat("yyyy-MM-dd").format(alarm);
1910 request.originalTime = new SimpleDateFormat("HH:mm:ss.SSSS").format(alarm);
1911 request.type = type;
1912 request.id = "create" + type;
1914 String data = gsonWithNullSerialization.toJson(request);
1915 String response = makeRequestAndReturnString("PUT", alexaServer + "/api/notifications/createReminder", data,
1917 JsonNotificationResponse result = parseJson(response, JsonNotificationResponse.class);
1921 public void stopNotification(JsonNotificationResponse notification) throws IOException, URISyntaxException {
1922 makeRequestAndReturnString("DELETE", alexaServer + "/api/notifications/" + notification.id, null, true, null);
1925 public @Nullable JsonNotificationResponse getNotificationState(JsonNotificationResponse notification)
1926 throws IOException, URISyntaxException {
1927 String response = makeRequestAndReturnString("GET", alexaServer + "/api/notifications/" + notification.id, null,
1929 JsonNotificationResponse result = parseJson(response, JsonNotificationResponse.class);
1933 public List<JsonMusicProvider> getMusicProviders() {
1936 Map<String, String> headers = new HashMap<>();
1937 headers.put("Routines-Version", "1.1.218665");
1938 response = makeRequestAndReturnString("GET",
1939 alexaServer + "/api/behaviors/entities?skillId=amzn1.ask.1p.music", null, true, headers);
1940 } catch (IOException | URISyntaxException e) {
1941 logger.warn("getMusicProviders fails: {}", e.getMessage());
1942 return new ArrayList<>();
1944 if (response == null || response.isEmpty()) {
1945 return new ArrayList<>();
1947 JsonMusicProvider[] result = parseJson(response, JsonMusicProvider[].class);
1948 return Arrays.asList(result);
1951 public void playMusicVoiceCommand(Device device, String providerId, String voiceCommand)
1952 throws IOException, URISyntaxException {
1953 JsonPlaySearchPhraseOperationPayload payload = new JsonPlaySearchPhraseOperationPayload();
1954 payload.customerId = (this.accountCustomerId == null || this.accountCustomerId.isEmpty()
1955 ? device.deviceOwnerCustomerId
1956 : this.accountCustomerId);
1957 payload.locale = "ALEXA_CURRENT_LOCALE";
1958 payload.musicProviderId = providerId;
1959 payload.searchPhrase = voiceCommand;
1961 String playloadString = gson.toJson(payload);
1963 JsonObject postValidationJson = new JsonObject();
1965 postValidationJson.addProperty("type", "Alexa.Music.PlaySearchPhrase");
1966 postValidationJson.addProperty("operationPayload", playloadString);
1968 String postDataValidate = postValidationJson.toString();
1970 String validateResultJson = makeRequestAndReturnString("POST",
1971 alexaServer + "/api/behaviors/operation/validate", postDataValidate, true, null);
1973 if (validateResultJson != null && !validateResultJson.isEmpty()) {
1974 JsonPlayValidationResult validationResult = parseJson(validateResultJson, JsonPlayValidationResult.class);
1975 if (validationResult != null) {
1976 JsonPlaySearchPhraseOperationPayload validatedOperationPayload = validationResult.operationPayload;
1977 if (validatedOperationPayload != null) {
1978 payload.sanitizedSearchPhrase = validatedOperationPayload.sanitizedSearchPhrase;
1979 payload.searchPhrase = validatedOperationPayload.searchPhrase;
1984 payload.locale = null;
1985 payload.deviceSerialNumber = device.serialNumber;
1986 payload.deviceType = device.deviceType;
1988 JsonObject sequenceJson = new JsonObject();
1989 sequenceJson.addProperty("@type", "com.amazon.alexa.behaviors.model.Sequence");
1990 JsonObject startNodeJson = new JsonObject();
1991 startNodeJson.addProperty("@type", "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode");
1992 startNodeJson.addProperty("type", "Alexa.Music.PlaySearchPhrase");
1993 startNodeJson.add("operationPayload", gson.toJsonTree(payload));
1994 sequenceJson.add("startNode", startNodeJson);
1996 JsonStartRoutineRequest startRoutineRequest = new JsonStartRoutineRequest();
1997 startRoutineRequest.sequenceJson = sequenceJson.toString();
1998 startRoutineRequest.status = null;
2000 String postData = gson.toJson(startRoutineRequest);
2001 makeRequest("POST", alexaServer + "/api/behaviors/preview", postData, true, true, null, 3);
2004 public @Nullable JsonEqualizer getEqualizer(Device device) throws IOException, URISyntaxException {
2005 String json = makeRequestAndReturnString(
2006 alexaServer + "/api/equalizer/" + device.serialNumber + "/" + device.deviceType);
2007 return parseJson(json, JsonEqualizer.class);
2010 public void setEqualizer(Device device, JsonEqualizer settings) throws IOException, URISyntaxException {
2011 String postData = gson.toJson(settings);
2012 makeRequest("POST", alexaServer + "/api/equalizer/" + device.serialNumber + "/" + device.deviceType, postData,
2013 true, true, null, 0);
2017 private static class Announcement {
2018 public List<Device> devices = new ArrayList<>();
2019 public String speak;
2020 public String bodyText;
2021 public @Nullable String title;
2022 public List<@Nullable Integer> ttsVolumes = new ArrayList<>();
2023 public List<@Nullable Integer> standardVolumes = new ArrayList<>();
2025 public Announcement(String speak, String bodyText, @Nullable String title) {
2027 this.bodyText = bodyText;
2033 private static class TextToSpeech {
2034 public List<Device> devices = new ArrayList<>();
2036 public List<@Nullable Integer> ttsVolumes = new ArrayList<>();
2037 public List<@Nullable Integer> standardVolumes = new ArrayList<>();
2039 public TextToSpeech(String text) {
2045 private static class Volume {
2046 public List<Device> devices = new ArrayList<>();
2048 public List<@Nullable Integer> volumes = new ArrayList<>();
2050 public Volume(int volume) {
2051 this.volume = volume;
2056 private static class QueueObject {
2057 public LinkedBlockingQueue<JsonObject> queue = new LinkedBlockingQueue<>();
2058 public AtomicBoolean queueRunning = new AtomicBoolean();
2059 public @Nullable ScheduledFuture<?> senderUnblockFuture;
2061 public void dispose() {
2063 queueRunning.set(false);
2064 if (senderUnblockFuture != null) {
2065 senderUnblockFuture.cancel(true);
2071 private static class ExecutionNodeObject {
2072 public List<String> types = new ArrayList<>();