2 * Copyright (c) 2010-2021 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;
30 import java.util.concurrent.ConcurrentHashMap;
31 import java.util.concurrent.Future;
32 import java.util.concurrent.LinkedBlockingQueue;
33 import java.util.concurrent.ScheduledExecutorService;
34 import java.util.concurrent.ScheduledFuture;
35 import java.util.concurrent.TimeUnit;
36 import java.util.concurrent.locks.Lock;
37 import java.util.concurrent.locks.ReentrantLock;
38 import java.util.regex.Matcher;
39 import java.util.regex.Pattern;
40 import java.util.stream.Collectors;
41 import java.util.stream.StreamSupport;
42 import java.util.zip.GZIPInputStream;
44 import javax.net.ssl.HttpsURLConnection;
46 import org.eclipse.jdt.annotation.NonNullByDefault;
47 import org.eclipse.jdt.annotation.Nullable;
48 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonActivities;
49 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonActivities.Activity;
50 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAnnouncementContent;
51 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAnnouncementTarget;
52 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAscendingAlarm;
53 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAscendingAlarm.AscendingAlarmModel;
54 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAutomation;
55 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAutomation.Payload;
56 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAutomation.Trigger;
57 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBluetoothStates;
58 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBootstrapResult;
59 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBootstrapResult.Authentication;
60 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDeviceNotificationState;
61 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDeviceNotificationState.DeviceNotificationState;
62 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDevices;
63 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDevices.Device;
64 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonEnabledFeeds;
65 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonEqualizer;
66 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonExchangeTokenResponse;
67 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonExchangeTokenResponse.Cookie;
68 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonFeed;
69 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonMediaState;
70 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonMusicProvider;
71 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNetworkDetails;
72 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationRequest;
73 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationResponse;
74 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationSound;
75 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationSounds;
76 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationsResponse;
77 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlaySearchPhraseOperationPayload;
78 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlayValidationResult;
79 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlayerState;
80 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlaylists;
81 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppRequest;
82 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse;
83 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Bearer;
84 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.DeviceInfo;
85 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Extensions;
86 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Response;
87 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Success;
88 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Tokens;
89 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRenewTokenResponse;
90 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonSmartHomeDevices.SmartHomeDevice;
91 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonSmartHomeGroups.SmartHomeGroup;
92 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonStartRoutineRequest;
93 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonUsersMeResponse;
94 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonWakeWords;
95 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonWakeWords.WakeWord;
96 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonWebSiteCookie;
97 import org.openhab.binding.amazonechocontrol.internal.jsons.SmartHomeBaseDevice;
98 import org.openhab.core.common.ThreadPoolManager;
99 import org.openhab.core.library.types.QuantityType;
100 import org.openhab.core.library.unit.SIUnits;
101 import org.openhab.core.util.HexUtils;
102 import org.slf4j.Logger;
103 import org.slf4j.LoggerFactory;
105 import com.google.gson.Gson;
106 import com.google.gson.GsonBuilder;
107 import com.google.gson.JsonArray;
108 import com.google.gson.JsonElement;
109 import com.google.gson.JsonObject;
110 import com.google.gson.JsonParseException;
111 import com.google.gson.JsonSyntaxException;
114 * The {@link Connection} is responsible for the connection to the amazon server
115 * and handling of the commands
117 * @author Michael Geramb - Initial contribution
120 public class Connection {
121 private static final String THING_THREADPOOL_NAME = "thingHandler";
122 private static final long EXPIRES_IN = 432000; // five days
123 private static final Pattern CHARSET_PATTERN = Pattern.compile("(?i)\\bcharset=\\s*\"?([^\\s;\"]*)");
124 private static final String DEVICE_TYPE = "A2IVLV5VM2W81";
126 private final Logger logger = LoggerFactory.getLogger(Connection.class);
128 protected final ScheduledExecutorService scheduler = ThreadPoolManager.getScheduledPool(THING_THREADPOOL_NAME);
130 private final Random rand = new Random();
131 private final CookieManager cookieManager = new CookieManager();
132 private final Gson gson;
133 private final Gson gsonWithNullSerialization;
135 private String amazonSite = "amazon.com";
136 private String alexaServer = "https://alexa.amazon.com";
137 private final String userAgent;
139 private String serial;
140 private String deviceId;
142 private @Nullable String refreshToken;
143 private @Nullable Date loginTime;
144 private @Nullable Date verifyTime;
145 private long renewTime = 0;
146 private @Nullable String deviceName;
147 private @Nullable String accountCustomerId;
148 private @Nullable String customerName;
150 private Map<Integer, AnnouncementWrapper> announcements = Collections.synchronizedMap(new LinkedHashMap<>());
151 private Map<Integer, TextToSpeech> textToSpeeches = Collections.synchronizedMap(new LinkedHashMap<>());
152 private Map<Integer, TextCommand> textCommands = Collections.synchronizedMap(new LinkedHashMap<>());
154 private Map<Integer, Volume> volumes = Collections.synchronizedMap(new LinkedHashMap<>());
155 private Map<String, LinkedBlockingQueue<QueueObject>> devices = Collections.synchronizedMap(new LinkedHashMap<>());
157 private final Map<TimerType, ScheduledFuture<?>> timers = new ConcurrentHashMap<>();
158 private final Map<TimerType, Lock> locks = new ConcurrentHashMap<>();
160 private enum TimerType {
168 public Connection(@Nullable Connection oldConnection, Gson gson) {
171 String serial = null;
172 String deviceId = null;
173 if (oldConnection != null) {
174 deviceId = oldConnection.getDeviceId();
175 frc = oldConnection.getFrc();
176 serial = oldConnection.getSerial();
182 byte[] frcBinary = new byte[313];
183 rand.nextBytes(frcBinary);
184 this.frc = Base64.getEncoder().encodeToString(frcBinary);
186 if (serial != null) {
187 this.serial = serial;
190 byte[] serialBinary = new byte[16];
191 rand.nextBytes(serialBinary);
192 this.serial = HexUtils.bytesToHex(serialBinary);
194 if (deviceId != null) {
195 this.deviceId = deviceId;
197 this.deviceId = generateDeviceId();
201 this.userAgent = "AmazonWebView/Amazon Alexa/2.2.223830.0/iOS/11.4.1/iPhone";
202 GsonBuilder gsonBuilder = new GsonBuilder();
203 gsonWithNullSerialization = gsonBuilder.create();
205 replaceTimer(TimerType.DEVICES,
206 scheduler.scheduleWithFixedDelay(this::handleExecuteSequenceNode, 0, 500, TimeUnit.MILLISECONDS));
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 devices.values().stream().anyMatch(
311 (queueObjects) -> (queueObjects.stream().anyMatch(queueObject -> queueObject.future != null)));
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 | InterruptedException 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 accountCustomerId = devices.stream().filter(device -> serial.equals(device.serialNumber)).findAny()
462 .map(device -> device.deviceOwnerCustomerId).orElse(null);
463 if (accountCustomerId == null || accountCustomerId.isEmpty()) {
464 accountCustomerId = devices.stream().filter(device -> "This Device".equals(device.accountName))
465 .findAny().map(device -> {
466 serial = Objects.requireNonNullElse(device.serialNumber, serial);
467 return device.deviceOwnerCustomerId;
470 this.accountCustomerId = accountCustomerId;
472 } catch (URISyntaxException | IOException | InterruptedException | ConnectionException e) {
473 logger.debug("Getting account customer Id failed", e);
478 private @Nullable Authentication tryGetBootstrap() throws IOException, URISyntaxException, InterruptedException {
479 HttpsURLConnection connection = makeRequest("GET", alexaServer + "/api/bootstrap", null, false, false, null, 0);
480 String contentType = connection.getContentType();
481 if (connection.getResponseCode() == 200 && contentType != null
482 && contentType.toLowerCase().startsWith("application/json")) {
484 String bootstrapResultJson = convertStream(connection);
485 JsonBootstrapResult result = parseJson(bootstrapResultJson, JsonBootstrapResult.class);
486 if (result != null) {
487 Authentication authentication = result.authentication;
488 if (authentication != null && authentication.authenticated) {
489 this.customerName = authentication.customerName;
490 if (this.accountCustomerId == null) {
491 this.accountCustomerId = authentication.customerId;
493 return authentication;
496 } catch (JsonSyntaxException | IllegalStateException e) {
497 logger.info("No valid json received", e);
504 public String convertStream(HttpsURLConnection connection) throws IOException {
505 InputStream input = connection.getInputStream();
510 InputStream readerStream;
511 if ("gzip".equalsIgnoreCase(connection.getContentEncoding())) {
512 readerStream = new GZIPInputStream(connection.getInputStream());
514 readerStream = input;
516 String contentType = connection.getContentType();
517 String charSet = null;
518 if (contentType != null) {
519 Matcher m = CHARSET_PATTERN.matcher(contentType);
521 charSet = m.group(1).trim().toUpperCase();
525 Scanner inputScanner = charSet == null || charSet.isEmpty()
526 ? new Scanner(readerStream, StandardCharsets.UTF_8.name())
527 : new Scanner(readerStream, charSet);
528 Scanner scannerWithoutDelimiter = inputScanner.useDelimiter("\\A");
529 String result = scannerWithoutDelimiter.hasNext() ? scannerWithoutDelimiter.next() : null;
530 inputScanner.close();
531 scannerWithoutDelimiter.close();
533 if (result == null) {
539 public String makeRequestAndReturnString(String url) throws IOException, URISyntaxException, InterruptedException {
540 return makeRequestAndReturnString("GET", url, null, false, null);
543 public String makeRequestAndReturnString(String verb, String url, @Nullable String postData, boolean json,
544 @Nullable Map<String, String> customHeaders) throws IOException, URISyntaxException, InterruptedException {
545 HttpsURLConnection connection = makeRequest(verb, url, postData, json, true, customHeaders, 3);
546 String result = convertStream(connection);
547 logger.debug("Result of {} {}:{}", verb, url, result);
551 public HttpsURLConnection makeRequest(String verb, String url, @Nullable String postData, boolean json,
552 boolean autoredirect, @Nullable Map<String, String> customHeaders, int badRequestRepeats)
553 throws IOException, URISyntaxException, InterruptedException {
554 String currentUrl = url;
555 int redirectCounter = 0;
556 int retryCounter = 0;
557 // loop for handling redirect and bad request, using automatic redirect is not
558 // possible, because all response headers must be catched
561 HttpsURLConnection connection = null;
563 logger.debug("Make request to {}", url);
564 connection = (HttpsURLConnection) new URL(currentUrl).openConnection();
565 connection.setRequestMethod(verb);
566 connection.setRequestProperty("Accept-Language", "en-US");
567 if (customHeaders == null || !customHeaders.containsKey("User-Agent")) {
568 connection.setRequestProperty("User-Agent", userAgent);
570 connection.setRequestProperty("Accept-Encoding", "gzip");
571 connection.setRequestProperty("DNT", "1");
572 connection.setRequestProperty("Upgrade-Insecure-Requests", "1");
573 if (customHeaders != null) {
574 for (String key : customHeaders.keySet()) {
575 String value = customHeaders.get(key);
576 if (value != null && !value.isEmpty()) {
577 connection.setRequestProperty(key, value);
581 connection.setInstanceFollowRedirects(false);
584 URI uri = connection.getURL().toURI();
586 if (customHeaders == null || !customHeaders.containsKey("Cookie")) {
587 StringBuilder cookieHeaderBuilder = new StringBuilder();
588 for (HttpCookie cookie : cookieManager.getCookieStore().get(uri)) {
589 if (cookieHeaderBuilder.length() > 0) {
590 cookieHeaderBuilder.append(";");
592 cookieHeaderBuilder.append(cookie.getName());
593 cookieHeaderBuilder.append("=");
594 cookieHeaderBuilder.append(cookie.getValue());
595 if (cookie.getName().equals("csrf")) {
596 connection.setRequestProperty("csrf", cookie.getValue());
600 if (cookieHeaderBuilder.length() > 0) {
601 String cookies = cookieHeaderBuilder.toString();
602 connection.setRequestProperty("Cookie", cookies);
605 if (postData != null) {
606 logger.debug("{}: {}", verb, postData);
608 byte[] postDataBytes = postData.getBytes(StandardCharsets.UTF_8);
609 int postDataLength = postDataBytes.length;
611 connection.setFixedLengthStreamingMode(postDataLength);
614 connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
616 connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
618 connection.setRequestProperty("Content-Length", Integer.toString(postDataLength));
619 if ("POST".equals(verb)) {
620 connection.setRequestProperty("Expect", "100-continue");
623 connection.setDoOutput(true);
624 OutputStream outputStream = connection.getOutputStream();
625 outputStream.write(postDataBytes);
626 outputStream.close();
629 code = connection.getResponseCode();
630 String location = null;
632 // handle response headers
633 Map<@Nullable String, List<String>> headerFields = connection.getHeaderFields();
634 for (Map.Entry<@Nullable String, List<String>> header : headerFields.entrySet()) {
635 String key = header.getKey();
636 if (key != null && !key.isEmpty()) {
637 if (key.equalsIgnoreCase("Set-Cookie")) {
639 for (String cookieHeader : header.getValue()) {
640 if (!cookieHeader.isEmpty()) {
641 List<HttpCookie> cookies = HttpCookie.parse(cookieHeader);
642 for (HttpCookie cookie : cookies) {
643 cookieManager.getCookieStore().add(uri, cookie);
648 if (key.equalsIgnoreCase("Location")) {
649 // get redirect location
650 location = header.getValue().get(0);
651 if (!location.isEmpty()) {
652 location = uri.resolve(location).toString();
654 if (location.toLowerCase().startsWith("http://")) {
656 location = "https://" + location.substring(7);
657 logger.debug("Redirect corrected to {}", location);
664 logger.debug("Call to {} succeeded", url);
666 } else if (code == 302 && location != null) {
667 logger.debug("Redirected to {}", location);
669 if (redirectCounter > 30) {
670 throw new ConnectionException("Too many redirects");
672 currentUrl = location;
674 continue; // repeat with new location
678 logger.debug("Retry call to {}", url);
680 if (retryCounter > badRequestRepeats) {
681 throw new HttpException(code,
682 verb + " url '" + url + "' failed: " + connection.getResponseMessage());
686 } catch (InterruptedException | InterruptedIOException e) {
687 if (connection != null) {
688 connection.disconnect();
690 logger.warn("Unable to wait for next call to {}", url, e);
692 } catch (IOException e) {
693 if (connection != null) {
694 connection.disconnect();
696 logger.warn("Request to url '{}' fails with unknown error", url, e);
698 } catch (Exception e) {
699 if (connection != null) {
700 connection.disconnect();
707 public String registerConnectionAsApp(String oAutRedirectUrl)
708 throws ConnectionException, IOException, URISyntaxException, InterruptedException {
709 URI oAutRedirectUri = new URI(oAutRedirectUrl);
711 Map<String, String> queryParameters = new LinkedHashMap<>();
712 String query = oAutRedirectUri.getQuery();
713 String[] pairs = query.split("&");
714 for (String pair : pairs) {
715 int idx = pair.indexOf("=");
716 queryParameters.put(URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8.name()),
717 URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8.name()));
719 String accessToken = queryParameters.get("openid.oa2.access_token");
721 Map<String, String> cookieMap = new HashMap<>();
723 List<JsonWebSiteCookie> webSiteCookies = new ArrayList<>();
724 for (HttpCookie cookie : getSessionCookies("https://www.amazon.com")) {
725 cookieMap.put(cookie.getName(), cookie.getValue());
726 webSiteCookies.add(new JsonWebSiteCookie(cookie.getName(), cookie.getValue()));
729 JsonWebSiteCookie[] webSiteCookiesArray = new JsonWebSiteCookie[webSiteCookies.size()];
730 webSiteCookiesArray = webSiteCookies.toArray(webSiteCookiesArray);
732 JsonRegisterAppRequest registerAppRequest = new JsonRegisterAppRequest(serial, accessToken, frc,
733 webSiteCookiesArray);
734 String registerAppRequestJson = gson.toJson(registerAppRequest);
736 HashMap<String, String> registerHeaders = new HashMap<>();
737 registerHeaders.put("x-amzn-identity-auth-domain", "api.amazon.com");
739 String registerAppResultJson = makeRequestAndReturnString("POST", "https://api.amazon.com/auth/register",
740 registerAppRequestJson, true, registerHeaders);
741 JsonRegisterAppResponse registerAppResponse = parseJson(registerAppResultJson, JsonRegisterAppResponse.class);
743 if (registerAppResponse == null) {
744 throw new ConnectionException("Error: No response received from register application");
746 Response response = registerAppResponse.response;
747 if (response == null) {
748 throw new ConnectionException("Error: No response received from register application");
750 Success success = response.success;
751 if (success == null) {
752 throw new ConnectionException("Error: No success received from register application");
754 Tokens tokens = success.tokens;
755 if (tokens == null) {
756 throw new ConnectionException("Error: No tokens received from register application");
758 Bearer bearer = tokens.bearer;
759 if (bearer == null) {
760 throw new ConnectionException("Error: No bearer received from register application");
762 String refreshToken = bearer.refreshToken;
763 this.refreshToken = refreshToken;
764 if (refreshToken == null || refreshToken.isEmpty()) {
765 throw new ConnectionException("Error: No refresh token received");
769 // Check which is the owner domain
770 String usersMeResponseJson = makeRequestAndReturnString("GET",
771 "https://alexa.amazon.com/api/users/me?platform=ios&version=2.2.223830.0", null, false, null);
772 JsonUsersMeResponse usersMeResponse = parseJson(usersMeResponseJson, JsonUsersMeResponse.class);
773 if (usersMeResponse == null) {
774 throw new IllegalArgumentException("Received no response on me-request");
776 URI uri = new URI(usersMeResponse.marketPlaceDomainName);
777 String host = uri.getHost();
779 // Switch to owner domain
783 } catch (Exception e) {
787 String deviceName = null;
788 Extensions extensions = success.extensions;
789 if (extensions != null) {
790 DeviceInfo deviceInfo = extensions.deviceInfo;
791 if (deviceInfo != null) {
792 deviceName = deviceInfo.deviceName;
795 if (deviceName == null) {
796 deviceName = "Unknown";
798 this.deviceName = deviceName;
802 private void exchangeToken() throws IOException, URISyntaxException, InterruptedException {
804 String cookiesJson = "{\"cookies\":{\"." + getAmazonSite() + "\":[]}}";
805 String cookiesBase64 = Base64.getEncoder().encodeToString(cookiesJson.getBytes());
807 String exchangePostData = "di.os.name=iOS&app_version=2.2.223830.0&domain=." + getAmazonSite()
808 + "&source_token=" + URLEncoder.encode(this.refreshToken, "UTF8")
809 + "&requested_token_type=auth_cookies&source_token_type=refresh_token&di.hw.version=iPhone&di.sdk.version=6.10.0&cookies="
810 + cookiesBase64 + "&app_name=Amazon%20Alexa&di.os.version=11.4.1";
812 HashMap<String, String> exchangeTokenHeader = new HashMap<>();
813 exchangeTokenHeader.put("Cookie", "");
815 String exchangeTokenJson = makeRequestAndReturnString("POST",
816 "https://www." + getAmazonSite() + "/ap/exchangetoken", exchangePostData, false, exchangeTokenHeader);
817 JsonExchangeTokenResponse exchangeTokenResponse = Objects
818 .requireNonNull(gson.fromJson(exchangeTokenJson, JsonExchangeTokenResponse.class));
820 org.openhab.binding.amazonechocontrol.internal.jsons.JsonExchangeTokenResponse.Response response = exchangeTokenResponse.response;
821 if (response != null) {
822 org.openhab.binding.amazonechocontrol.internal.jsons.JsonExchangeTokenResponse.Tokens tokens = response.tokens;
823 if (tokens != null) {
824 Map<String, Cookie[]> cookiesMap = tokens.cookies;
825 if (cookiesMap != null) {
826 for (String domain : cookiesMap.keySet()) {
827 Cookie[] cookies = cookiesMap.get(domain);
828 if (cookies != null) {
829 for (Cookie cookie : cookies) {
830 if (cookie != null) {
831 HttpCookie httpCookie = new HttpCookie(cookie.name, cookie.value);
832 httpCookie.setPath(cookie.path);
833 httpCookie.setDomain(domain);
834 Boolean secure = cookie.secure;
835 if (secure != null) {
836 httpCookie.setSecure(secure);
838 this.cookieManager.getCookieStore().add(null, httpCookie);
846 if (!verifyLogin()) {
847 throw new ConnectionException("Verify login failed after token exchange");
849 this.renewTime = (long) (System.currentTimeMillis() + Connection.EXPIRES_IN * 1000d / 0.8d); // start renew at
852 public boolean checkRenewSession() throws URISyntaxException, IOException, InterruptedException {
853 if (System.currentTimeMillis() >= this.renewTime) {
854 String renewTokenPostData = "app_name=Amazon%20Alexa&app_version=2.2.223830.0&di.sdk.version=6.10.0&source_token="
855 + URLEncoder.encode(refreshToken, StandardCharsets.UTF_8.name())
856 + "&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";
857 String renewTokenResponseJson = makeRequestAndReturnString("POST", "https://api.amazon.com/auth/token",
858 renewTokenPostData, false, null);
859 parseJson(renewTokenResponseJson, JsonRenewTokenResponse.class);
867 public boolean getIsLoggedIn() {
868 return loginTime != null;
871 public String getLoginPage() throws IOException, URISyntaxException, InterruptedException {
872 // clear session data
875 logger.debug("Start Login to {}", alexaServer);
877 if (!checkDeviceIdIsValid(deviceId)) {
878 deviceId = generateDeviceId();
879 logger.debug("Generating new device id (old device id had invalid format).");
882 String mapMdJson = "{\"device_user_dictionary\":[],\"device_registration_data\":{\"software_version\":\"1\"},\"app_identifier\":{\"app_version\":\"2.2.223830\",\"bundle_id\":\"com.amazon.echo\"}}";
883 String mapMdCookie = Base64.getEncoder().encodeToString(mapMdJson.getBytes());
885 cookieManager.getCookieStore().add(new URI("https://www.amazon.com"), new HttpCookie("map-md", mapMdCookie));
886 cookieManager.getCookieStore().add(new URI("https://www.amazon.com"), new HttpCookie("frc", frc));
888 Map<String, String> customHeaders = new HashMap<>();
889 customHeaders.put("authority", "www.amazon.com");
890 String loginFormHtml = makeRequestAndReturnString("GET", "https://www.amazon.com"
891 + "/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:"
893 + "&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",
894 null, false, customHeaders);
896 logger.debug("Received login form {}", loginFormHtml);
897 return loginFormHtml;
900 public boolean verifyLogin() throws IOException, URISyntaxException, InterruptedException {
901 if (this.refreshToken == null) {
904 Authentication authentication = tryGetBootstrap();
905 if (authentication != null && authentication.authenticated) {
906 verifyTime = new Date();
907 if (loginTime == null) {
908 loginTime = verifyTime;
915 public List<HttpCookie> getSessionCookies() {
917 return cookieManager.getCookieStore().get(new URI(alexaServer));
918 } catch (URISyntaxException e) {
919 return new ArrayList<>();
923 public List<HttpCookie> getSessionCookies(String server) {
925 return cookieManager.getCookieStore().get(new URI(server));
926 } catch (URISyntaxException e) {
927 return new ArrayList<>();
931 private void replaceTimer(TimerType type, @Nullable ScheduledFuture<?> newTimer) {
932 timers.compute(type, (timerType, oldTimer) -> {
933 if (oldTimer != null) {
934 oldTimer.cancel(true);
940 public void logout() {
941 cookieManager.getCookieStore().removeAll();
948 replaceTimer(TimerType.ANNOUNCEMENT, null);
949 announcements.clear();
950 replaceTimer(TimerType.TTS, null);
951 textToSpeeches.clear();
952 replaceTimer(TimerType.VOLUME, null);
954 replaceTimer(TimerType.DEVICES, null);
955 textCommands.clear();
956 replaceTimer(TimerType.TTS, null);
958 devices.values().forEach((queueObjects) -> {
959 queueObjects.forEach((queueObject) -> {
960 Future<?> future = queueObject.future;
961 if (future != null) {
963 queueObject.future = null;
970 private <T> @Nullable T parseJson(String json, Class<T> type) throws JsonSyntaxException, IllegalStateException {
972 return gson.fromJson(json, type);
973 } catch (JsonParseException | IllegalStateException e) {
974 logger.warn("Parsing json failed: {}", json, e);
979 // commands and states
980 public WakeWord[] getWakeWords() {
983 json = makeRequestAndReturnString(alexaServer + "/api/wake-word?cached=true");
984 JsonWakeWords wakeWords = parseJson(json, JsonWakeWords.class);
985 if (wakeWords != null) {
986 WakeWord[] result = wakeWords.wakeWords;
987 if (result != null) {
991 } catch (IOException | URISyntaxException | InterruptedException e) {
992 logger.info("getting wakewords failed", e);
994 return new WakeWord[0];
997 public List<SmartHomeBaseDevice> getSmarthomeDeviceList()
998 throws IOException, URISyntaxException, InterruptedException {
1000 String json = makeRequestAndReturnString(alexaServer + "/api/phoenix");
1001 logger.debug("getSmartHomeDevices result: {}", json);
1003 JsonNetworkDetails networkDetails = parseJson(json, JsonNetworkDetails.class);
1004 if (networkDetails == null) {
1005 throw new IllegalArgumentException("received no response on network detail request");
1007 Object jsonObject = gson.fromJson(networkDetails.networkDetail, Object.class);
1008 List<SmartHomeBaseDevice> result = new ArrayList<>();
1009 searchSmartHomeDevicesRecursive(jsonObject, result);
1012 } catch (Exception e) {
1013 logger.warn("getSmartHomeDevices fails: {}", e.getMessage());
1018 private void searchSmartHomeDevicesRecursive(@Nullable Object jsonNode, List<SmartHomeBaseDevice> devices) {
1019 if (jsonNode instanceof Map) {
1020 @SuppressWarnings("rawtypes")
1021 Map<String, Object> map = (Map) jsonNode;
1022 if (map.containsKey("entityId") && map.containsKey("friendlyName") && map.containsKey("actions")) {
1023 // device node found, create type element and add it to the results
1024 JsonElement element = gson.toJsonTree(jsonNode);
1025 SmartHomeDevice shd = parseJson(element.toString(), SmartHomeDevice.class);
1029 } else if (map.containsKey("applianceGroupName")) {
1030 JsonElement element = gson.toJsonTree(jsonNode);
1031 SmartHomeGroup shg = parseJson(element.toString(), SmartHomeGroup.class);
1036 map.values().forEach(value -> searchSmartHomeDevicesRecursive(value, devices));
1041 public List<Device> getDeviceList() throws IOException, URISyntaxException, InterruptedException {
1042 JsonDevices devices = Objects.requireNonNull(parseJson(getDeviceListJson(), JsonDevices.class));
1043 logger.trace("Devices {}", devices.devices);
1045 // @Nullable because of a limitation of the null-checker, we filter null-serialNumbers before
1046 Set<@Nullable String> serialNumbers = ConcurrentHashMap.newKeySet();
1047 return devices.devices.stream().filter(d -> d.serialNumber != null && serialNumbers.add(d.serialNumber))
1048 .collect(Collectors.toList());
1051 public String getDeviceListJson() throws IOException, URISyntaxException, InterruptedException {
1052 String json = makeRequestAndReturnString(alexaServer + "/api/devices-v2/device?cached=false");
1056 public Map<String, JsonArray> getSmartHomeDeviceStatesJson(Set<SmartHomeBaseDevice> devices)
1057 throws IOException, URISyntaxException, InterruptedException {
1058 JsonObject requestObject = new JsonObject();
1059 JsonArray stateRequests = new JsonArray();
1060 Map<String, String> mergedApplianceMap = new HashMap<>();
1061 for (SmartHomeBaseDevice device : devices) {
1062 String applianceId = device.findId();
1063 if (applianceId != null) {
1064 JsonObject stateRequest;
1065 if (device instanceof SmartHomeDevice && !((SmartHomeDevice) device).mergedApplianceIds.isEmpty()) {
1066 for (String idToMerge : ((SmartHomeDevice) device).mergedApplianceIds) {
1067 mergedApplianceMap.put(idToMerge, applianceId);
1068 stateRequest = new JsonObject();
1069 stateRequest.addProperty("entityId", idToMerge);
1070 stateRequest.addProperty("entityType", "APPLIANCE");
1071 stateRequests.add(stateRequest);
1074 stateRequest = new JsonObject();
1075 stateRequest.addProperty("entityId", applianceId);
1076 stateRequest.addProperty("entityType", "APPLIANCE");
1077 stateRequests.add(stateRequest);
1081 requestObject.add("stateRequests", stateRequests);
1082 String requestBody = requestObject.toString();
1083 String json = makeRequestAndReturnString("POST", alexaServer + "/api/phoenix/state", requestBody, true, null);
1084 logger.trace("Requested {} and received {}", requestBody, json);
1086 JsonObject responseObject = Objects.requireNonNull(gson.fromJson(json, JsonObject.class));
1087 JsonArray deviceStates = (JsonArray) responseObject.get("deviceStates");
1088 Map<String, JsonArray> result = new HashMap<>();
1089 for (JsonElement deviceState : deviceStates) {
1090 JsonObject deviceStateObject = deviceState.getAsJsonObject();
1091 JsonObject entity = deviceStateObject.get("entity").getAsJsonObject();
1092 String applianceId = entity.get("entityId").getAsString();
1093 JsonElement capabilityState = deviceStateObject.get("capabilityStates");
1094 if (capabilityState != null && capabilityState.isJsonArray()) {
1095 String realApplianceId = mergedApplianceMap.get(applianceId);
1096 if (realApplianceId != null) {
1097 var capabilityArray = result.get(realApplianceId);
1098 if (capabilityArray != null) {
1099 capabilityArray.addAll(capabilityState.getAsJsonArray());
1100 result.put(realApplianceId, capabilityArray);
1102 result.put(realApplianceId, capabilityState.getAsJsonArray());
1105 result.put(applianceId, capabilityState.getAsJsonArray());
1112 public @Nullable JsonPlayerState getPlayer(Device device)
1113 throws IOException, URISyntaxException, InterruptedException {
1114 String json = makeRequestAndReturnString(alexaServer + "/api/np/player?deviceSerialNumber="
1115 + device.serialNumber + "&deviceType=" + device.deviceType + "&screenWidth=1440");
1116 JsonPlayerState playerState = parseJson(json, JsonPlayerState.class);
1120 public @Nullable JsonMediaState getMediaState(Device device)
1121 throws IOException, URISyntaxException, InterruptedException {
1122 String json = makeRequestAndReturnString(alexaServer + "/api/media/state?deviceSerialNumber="
1123 + device.serialNumber + "&deviceType=" + device.deviceType);
1124 JsonMediaState mediaState = parseJson(json, JsonMediaState.class);
1128 public Activity[] getActivities(int number, @Nullable Long startTime) {
1131 json = makeRequestAndReturnString(alexaServer + "/api/activities?startTime="
1132 + (startTime != null ? startTime : "") + "&size=" + number + "&offset=1");
1133 JsonActivities activities = parseJson(json, JsonActivities.class);
1134 if (activities != null) {
1135 Activity[] activiesArray = activities.activities;
1136 if (activiesArray != null) {
1137 return activiesArray;
1140 } catch (IOException | URISyntaxException | InterruptedException e) {
1141 logger.info("getting activities failed", e);
1143 return new Activity[0];
1146 public @Nullable JsonBluetoothStates getBluetoothConnectionStates() {
1149 json = makeRequestAndReturnString(alexaServer + "/api/bluetooth?cached=true");
1150 } catch (IOException | URISyntaxException | InterruptedException e) {
1151 logger.debug("failed to get bluetooth state: {}", e.getMessage());
1152 return new JsonBluetoothStates();
1154 JsonBluetoothStates bluetoothStates = parseJson(json, JsonBluetoothStates.class);
1155 return bluetoothStates;
1158 public @Nullable JsonPlaylists getPlaylists(Device device)
1159 throws IOException, URISyntaxException, InterruptedException {
1160 String json = makeRequestAndReturnString(
1161 alexaServer + "/api/cloudplayer/playlists?deviceSerialNumber=" + device.serialNumber + "&deviceType="
1162 + device.deviceType + "&mediaOwnerCustomerId=" + getCustomerId(device.deviceOwnerCustomerId));
1163 JsonPlaylists playlists = parseJson(json, JsonPlaylists.class);
1167 public void command(Device device, String command) throws IOException, URISyntaxException, InterruptedException {
1168 String url = alexaServer + "/api/np/command?deviceSerialNumber=" + device.serialNumber + "&deviceType="
1169 + device.deviceType;
1170 makeRequest("POST", url, command, true, true, null, 0);
1173 public void smartHomeCommand(String entityId, String action) throws IOException, InterruptedException {
1174 smartHomeCommand(entityId, action, null, null);
1177 public void smartHomeCommand(String entityId, String action, @Nullable String property, @Nullable Object value)
1178 throws IOException, InterruptedException {
1179 String url = alexaServer + "/api/phoenix/state";
1181 JsonObject json = new JsonObject();
1182 JsonArray controlRequests = new JsonArray();
1183 JsonObject controlRequest = new JsonObject();
1184 controlRequest.addProperty("entityId", entityId);
1185 controlRequest.addProperty("entityType", "APPLIANCE");
1186 JsonObject parameters = new JsonObject();
1187 parameters.addProperty("action", action);
1188 if (property != null) {
1189 if (value instanceof QuantityType<?>) {
1190 parameters.addProperty(property + ".value", ((QuantityType<?>) value).floatValue());
1191 parameters.addProperty(property + ".scale",
1192 ((QuantityType<?>) value).getUnit().equals(SIUnits.CELSIUS) ? "celsius" : "fahrenheit");
1193 } else if (value instanceof Boolean) {
1194 parameters.addProperty(property, (boolean) value);
1195 } else if (value instanceof String) {
1196 parameters.addProperty(property, (String) value);
1197 } else if (value instanceof Number) {
1198 parameters.addProperty(property, (Number) value);
1199 } else if (value instanceof Character) {
1200 parameters.addProperty(property, (Character) value);
1201 } else if (value instanceof JsonElement) {
1202 parameters.add(property, (JsonElement) value);
1205 controlRequest.add("parameters", parameters);
1206 controlRequests.add(controlRequest);
1207 json.add("controlRequests", controlRequests);
1209 String requestBody = json.toString();
1211 String resultBody = makeRequestAndReturnString("PUT", url, requestBody, true, null);
1212 logger.trace("Request '{}' resulted in '{}", requestBody, resultBody);
1213 JsonObject result = parseJson(resultBody, JsonObject.class);
1214 if (result != null) {
1215 JsonElement errors = result.get("errors");
1216 if (errors != null && errors.isJsonArray()) {
1217 JsonArray errorList = errors.getAsJsonArray();
1218 if (errorList.size() > 0) {
1219 logger.warn("Smart home device command failed. The request '{}' resulted in error(s): {}",
1220 requestBody, StreamSupport.stream(errorList.spliterator(), false)
1221 .map(JsonElement::toString).collect(Collectors.joining(" / ")));
1225 } catch (URISyntaxException e) {
1226 logger.warn("URL '{}' has invalid format for request '{}': {}", url, requestBody, e.getMessage());
1230 public void notificationVolume(Device device, int volume)
1231 throws IOException, URISyntaxException, InterruptedException {
1232 String url = alexaServer + "/api/device-notification-state/" + device.deviceType + "/" + device.softwareVersion
1233 + "/" + device.serialNumber;
1234 String command = "{\"deviceSerialNumber\":\"" + device.serialNumber + "\",\"deviceType\":\"" + device.deviceType
1235 + "\",\"softwareVersion\":\"" + device.softwareVersion + "\",\"volumeLevel\":" + volume + "}";
1236 makeRequest("PUT", url, command, true, true, null, 0);
1239 public void ascendingAlarm(Device device, boolean ascendingAlarm)
1240 throws IOException, URISyntaxException, InterruptedException {
1241 String url = alexaServer + "/api/ascending-alarm/" + device.serialNumber;
1242 String command = "{\"ascendingAlarmEnabled\":" + (ascendingAlarm ? "true" : "false")
1243 + ",\"deviceSerialNumber\":\"" + device.serialNumber + "\",\"deviceType\":\"" + device.deviceType
1244 + "\",\"deviceAccountId\":null}";
1245 makeRequest("PUT", url, command, true, true, null, 0);
1248 public DeviceNotificationState[] getDeviceNotificationStates() {
1251 json = makeRequestAndReturnString(alexaServer + "/api/device-notification-state");
1252 JsonDeviceNotificationState result = parseJson(json, JsonDeviceNotificationState.class);
1253 if (result != null) {
1254 DeviceNotificationState[] deviceNotificationStates = result.deviceNotificationStates;
1255 if (deviceNotificationStates != null) {
1256 return deviceNotificationStates;
1259 } catch (IOException | URISyntaxException | InterruptedException e) {
1260 logger.info("Error getting device notification states", e);
1262 return new DeviceNotificationState[0];
1265 public AscendingAlarmModel[] getAscendingAlarm() {
1268 json = makeRequestAndReturnString(alexaServer + "/api/ascending-alarm");
1269 JsonAscendingAlarm result = parseJson(json, JsonAscendingAlarm.class);
1270 if (result != null) {
1271 AscendingAlarmModel[] ascendingAlarmModelList = result.ascendingAlarmModelList;
1272 if (ascendingAlarmModelList != null) {
1273 return ascendingAlarmModelList;
1276 } catch (IOException | URISyntaxException | InterruptedException e) {
1277 logger.info("Error getting device notification states", e);
1279 return new AscendingAlarmModel[0];
1282 public void bluetooth(Device device, @Nullable String address)
1283 throws IOException, URISyntaxException, InterruptedException {
1284 if (address == null || address.isEmpty()) {
1287 alexaServer + "/api/bluetooth/disconnect-sink/" + device.deviceType + "/" + device.serialNumber, "",
1288 true, true, null, 0);
1291 alexaServer + "/api/bluetooth/pair-sink/" + device.deviceType + "/" + device.serialNumber,
1292 "{\"bluetoothDeviceAddress\":\"" + address + "\"}", true, true, null, 0);
1296 private @Nullable String getCustomerId(@Nullable String defaultId) {
1297 String accountCustomerId = this.accountCustomerId;
1298 return accountCustomerId == null || accountCustomerId.isEmpty() ? defaultId : accountCustomerId;
1301 public void playRadio(Device device, @Nullable String stationId)
1302 throws IOException, URISyntaxException, InterruptedException {
1303 if (stationId == null || stationId.isEmpty()) {
1304 command(device, "{\"type\":\"PauseCommand\"}");
1307 alexaServer + "/api/tunein/queue-and-play?deviceSerialNumber=" + device.serialNumber
1308 + "&deviceType=" + device.deviceType + "&guideId=" + stationId
1309 + "&contentType=station&callSign=&mediaOwnerCustomerId="
1310 + getCustomerId(device.deviceOwnerCustomerId),
1311 "", true, true, null, 0);
1315 public void playAmazonMusicTrack(Device device, @Nullable String trackId)
1316 throws IOException, URISyntaxException, InterruptedException {
1317 if (trackId == null || trackId.isEmpty()) {
1318 command(device, "{\"type\":\"PauseCommand\"}");
1320 String command = "{\"trackId\":\"" + trackId + "\",\"playQueuePrime\":true}";
1322 alexaServer + "/api/cloudplayer/queue-and-play?deviceSerialNumber=" + device.serialNumber
1323 + "&deviceType=" + device.deviceType + "&mediaOwnerCustomerId="
1324 + getCustomerId(device.deviceOwnerCustomerId) + "&shuffle=false",
1325 command, true, true, null, 0);
1329 public void playAmazonMusicPlayList(Device device, @Nullable String playListId)
1330 throws IOException, URISyntaxException, InterruptedException {
1331 if (playListId == null || playListId.isEmpty()) {
1332 command(device, "{\"type\":\"PauseCommand\"}");
1334 String command = "{\"playlistId\":\"" + playListId + "\",\"playQueuePrime\":true}";
1336 alexaServer + "/api/cloudplayer/queue-and-play?deviceSerialNumber=" + device.serialNumber
1337 + "&deviceType=" + device.deviceType + "&mediaOwnerCustomerId="
1338 + getCustomerId(device.deviceOwnerCustomerId) + "&shuffle=false",
1339 command, true, true, null, 0);
1343 public void announcement(Device device, String speak, String bodyText, @Nullable String title,
1344 @Nullable Integer ttsVolume, @Nullable Integer standardVolume) {
1345 String plainSpeak = speak.replaceAll("<.+?>", " ").replaceAll("\\s+", " ").trim();
1346 String plainBody = bodyText.replaceAll("<.+?>", " ").replaceAll("\\s+", " ").trim();
1348 if (plainSpeak.isEmpty() && plainBody.isEmpty()) {
1349 // if there is neither a bodytext nor (except tags) a speaktext, we have nothing to announce
1353 // we lock announcements until we have finished adding this one
1354 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.ANNOUNCEMENT, k -> new ReentrantLock()));
1357 AnnouncementWrapper announcement = Objects.requireNonNull(announcements.computeIfAbsent(
1358 Objects.hash(speak, plainBody, title), k -> new AnnouncementWrapper(speak, plainBody, title)));
1359 announcement.devices.add(device);
1360 announcement.ttsVolumes.add(ttsVolume);
1361 announcement.standardVolumes.add(standardVolume);
1363 // schedule an announcement only if it has not been scheduled before
1364 timers.computeIfAbsent(TimerType.ANNOUNCEMENT,
1365 k -> scheduler.schedule(this::sendAnnouncement, 500, TimeUnit.MILLISECONDS));
1371 private void sendAnnouncement() {
1372 // we lock new announcements until we have dispatched everything
1373 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.ANNOUNCEMENT, k -> new ReentrantLock()));
1376 Iterator<AnnouncementWrapper> iterator = announcements.values().iterator();
1377 while (iterator.hasNext()) {
1378 AnnouncementWrapper announcement = iterator.next();
1380 List<Device> devices = announcement.devices;
1381 if (!devices.isEmpty()) {
1382 JsonAnnouncementContent content = new JsonAnnouncementContent(announcement);
1384 Map<String, Object> parameters = new HashMap<>();
1385 parameters.put("expireAfter", "PT5S");
1386 parameters.put("content", new JsonAnnouncementContent[] { content });
1387 parameters.put("target", new JsonAnnouncementTarget(devices));
1389 String customerId = getCustomerId(devices.get(0).deviceOwnerCustomerId);
1390 if (customerId != null) {
1391 parameters.put("customerId", customerId);
1393 executeSequenceCommandWithVolume(devices, "AlexaAnnouncement", parameters,
1394 announcement.ttsVolumes, announcement.standardVolumes);
1396 } catch (Exception e) {
1397 logger.warn("send announcement fails with unexpected error", e);
1402 // the timer is done anyway immediately after we unlock
1403 timers.remove(TimerType.ANNOUNCEMENT);
1408 public void textToSpeech(Device device, String text, @Nullable Integer ttsVolume,
1409 @Nullable Integer standardVolume) {
1410 if (text.replaceAll("<.+?>", "").replaceAll("\\s+", " ").trim().isEmpty()) {
1414 // we lock TTS until we have finished adding this one
1415 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.TTS, k -> new ReentrantLock()));
1418 TextToSpeech textToSpeech = Objects
1419 .requireNonNull(textToSpeeches.computeIfAbsent(Objects.hash(text), k -> new TextToSpeech(text)));
1420 textToSpeech.devices.add(device);
1421 textToSpeech.ttsVolumes.add(ttsVolume);
1422 textToSpeech.standardVolumes.add(standardVolume);
1423 // schedule a TTS only if it has not been scheduled before
1424 timers.computeIfAbsent(TimerType.TTS,
1425 k -> scheduler.schedule(this::sendTextToSpeech, 500, TimeUnit.MILLISECONDS));
1431 private void sendTextToSpeech() {
1432 // we lock new TTS until we have dispatched everything
1433 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.TTS, k -> new ReentrantLock()));
1436 Iterator<TextToSpeech> iterator = textToSpeeches.values().iterator();
1437 while (iterator.hasNext()) {
1438 TextToSpeech textToSpeech = iterator.next();
1440 List<Device> devices = textToSpeech.devices;
1441 if (!devices.isEmpty()) {
1442 String text = textToSpeech.text;
1443 Map<String, Object> parameters = Map.of("textToSpeak", text);
1444 executeSequenceCommandWithVolume(devices, "Alexa.Speak", parameters, textToSpeech.ttsVolumes,
1445 textToSpeech.standardVolumes);
1447 } catch (Exception e) {
1448 logger.warn("send textToSpeech fails with unexpected error", e);
1453 // the timer is done anyway immediately after we unlock
1454 timers.remove(TimerType.TTS);
1459 public void textCommand(Device device, String text, @Nullable Integer ttsVolume, @Nullable Integer standardVolume) {
1460 if (text.replaceAll("<.+?>", "").replaceAll("\\s+", " ").trim().isEmpty()) {
1464 // we lock TextCommands until we have finished adding this one
1465 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.TEXT_COMMAND, k -> new ReentrantLock()));
1468 TextCommand textCommand = Objects
1469 .requireNonNull(textCommands.computeIfAbsent(Objects.hash(text), k -> new TextCommand(text)));
1470 textCommand.devices.add(device);
1471 textCommand.ttsVolumes.add(ttsVolume);
1472 textCommand.standardVolumes.add(standardVolume);
1473 // schedule a TextCommand only if it has not been scheduled before
1474 timers.computeIfAbsent(TimerType.TEXT_COMMAND,
1475 k -> scheduler.schedule(this::sendTextCommand, 500, TimeUnit.MILLISECONDS));
1481 private synchronized void sendTextCommand() {
1482 // we lock new TTS until we have dispatched everything
1483 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.TEXT_COMMAND, k -> new ReentrantLock()));
1487 Iterator<TextCommand> iterator = textCommands.values().iterator();
1488 while (iterator.hasNext()) {
1489 TextCommand textCommand = iterator.next();
1491 List<Device> devices = textCommand.devices;
1492 if (!devices.isEmpty()) {
1493 String text = textCommand.text;
1494 Map<String, Object> parameters = Map.of("text", text);
1495 executeSequenceCommandWithVolume(devices, "Alexa.TextCommand", parameters,
1496 textCommand.ttsVolumes, textCommand.standardVolumes);
1498 } catch (Exception e) {
1499 logger.warn("send textCommand fails with unexpected error", e);
1504 // the timer is done anyway immediately after we unlock
1505 timers.remove(TimerType.TEXT_COMMAND);
1510 public void volume(Device device, int vol) {
1511 // we lock volume until we have finished adding this one
1512 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.VOLUME, k -> new ReentrantLock()));
1515 Volume volume = Objects.requireNonNull(volumes.computeIfAbsent(vol, k -> new Volume(vol)));
1516 volume.devices.add(device);
1517 volume.volumes.add(vol);
1518 // schedule a TTS only if it has not been scheduled before
1519 timers.computeIfAbsent(TimerType.VOLUME,
1520 k -> scheduler.schedule(this::sendVolume, 500, TimeUnit.MILLISECONDS));
1526 private void sendVolume() {
1527 // we lock new volume until we have dispatched everything
1528 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.VOLUME, k -> new ReentrantLock()));
1531 Iterator<Volume> iterator = volumes.values().iterator();
1532 while (iterator.hasNext()) {
1533 Volume volume = iterator.next();
1535 List<Device> devices = volume.devices;
1536 if (!devices.isEmpty()) {
1537 executeSequenceCommandWithVolume(devices, null, Map.of(), volume.volumes, List.of());
1539 } catch (Exception e) {
1540 logger.warn("send volume fails with unexpected error", e);
1545 // the timer is done anyway immediately after we unlock
1546 timers.remove(TimerType.VOLUME);
1551 private void executeSequenceCommandWithVolume(List<Device> devices, @Nullable String command,
1552 Map<String, Object> parameters, List<@Nullable Integer> ttsVolumes,
1553 List<@Nullable Integer> standardVolumes) {
1554 JsonArray serialNodesToExecute = new JsonArray();
1555 JsonArray ttsVolumeNodesToExecute = new JsonArray();
1556 for (int i = 0; i < devices.size(); i++) {
1557 Integer ttsVolume = ttsVolumes.size() > i ? ttsVolumes.get(i) : null;
1558 Integer standardVolume = standardVolumes.size() > i ? standardVolumes.get(i) : null;
1559 if (ttsVolume != null && (standardVolume != null || !ttsVolume.equals(standardVolume))) {
1560 ttsVolumeNodesToExecute.add(
1561 createExecutionNode(devices.get(i), "Alexa.DeviceControls.Volume", Map.of("value", ttsVolume)));
1564 if (ttsVolumeNodesToExecute.size() > 0) {
1565 JsonObject parallelNodesToExecute = new JsonObject();
1566 parallelNodesToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1567 parallelNodesToExecute.add("nodesToExecute", ttsVolumeNodesToExecute);
1568 serialNodesToExecute.add(parallelNodesToExecute);
1571 if (command != null && !parameters.isEmpty()) {
1572 JsonArray commandNodesToExecute = new JsonArray();
1573 if ("Alexa.Speak".equals(command) || "Alexa.TextCommand".equals(command)) {
1574 for (Device device : devices) {
1575 commandNodesToExecute.add(createExecutionNode(device, command, parameters));
1578 commandNodesToExecute.add(createExecutionNode(devices.get(0), command, parameters));
1580 if (commandNodesToExecute.size() > 0) {
1581 JsonObject parallelNodesToExecute = new JsonObject();
1582 parallelNodesToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1583 parallelNodesToExecute.add("nodesToExecute", commandNodesToExecute);
1584 serialNodesToExecute.add(parallelNodesToExecute);
1588 JsonArray standardVolumeNodesToExecute = new JsonArray();
1589 for (int i = 0; i < devices.size(); i++) {
1590 Integer ttsVolume = ttsVolumes.size() > i ? ttsVolumes.get(i) : null;
1591 Integer standardVolume = standardVolumes.size() > i ? standardVolumes.get(i) : null;
1592 if (ttsVolume != null && standardVolume != null && !ttsVolume.equals(standardVolume)) {
1593 standardVolumeNodesToExecute.add(createExecutionNode(devices.get(i), "Alexa.DeviceControls.Volume",
1594 Map.of("value", standardVolume)));
1597 if (standardVolumeNodesToExecute.size() > 0) {
1598 JsonObject parallelNodesToExecute = new JsonObject();
1599 parallelNodesToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1600 parallelNodesToExecute.add("nodesToExecute", standardVolumeNodesToExecute);
1601 serialNodesToExecute.add(parallelNodesToExecute);
1604 if (serialNodesToExecute.size() > 0) {
1605 executeSequenceNodes(devices, serialNodesToExecute, false);
1609 // commands: Alexa.Weather.Play, Alexa.Traffic.Play, Alexa.FlashBriefing.Play,
1610 // Alexa.GoodMorning.Play,
1611 // Alexa.SingASong.Play, Alexa.TellStory.Play, Alexa.Speak (textToSpeach)
1612 public void executeSequenceCommand(Device device, String command, Map<String, Object> parameters) {
1613 JsonObject nodeToExecute = createExecutionNode(device, command, parameters);
1614 executeSequenceNode(List.of(device), nodeToExecute);
1617 private void executeSequenceNode(List<Device> devices, JsonObject nodeToExecute) {
1618 QueueObject queueObject = new QueueObject();
1619 queueObject.devices = devices;
1620 queueObject.nodeToExecute = nodeToExecute;
1621 String serialNumbers = "";
1622 for (Device device : devices) {
1623 String serialNumber = device.serialNumber;
1624 if (serialNumber != null) {
1625 Objects.requireNonNull(this.devices.computeIfAbsent(serialNumber, k -> new LinkedBlockingQueue<>()))
1626 .offer(queueObject);
1627 serialNumbers = serialNumbers + device.serialNumber + " ";
1630 logger.debug("added {} device {}", queueObject.hashCode(), serialNumbers);
1633 private void handleExecuteSequenceNode() {
1634 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.DEVICES, k -> new ReentrantLock()));
1635 if (lock.tryLock()) {
1637 for (String serialNumber : devices.keySet()) {
1638 LinkedBlockingQueue<QueueObject> queueObjects = devices.get(serialNumber);
1639 if (queueObjects != null) {
1640 QueueObject queueObject = queueObjects.peek();
1641 if (queueObject != null) {
1642 Future<?> future = queueObject.future;
1643 if (future == null || future.isDone()) {
1644 boolean execute = true;
1646 for (Device tmpDevice : queueObject.devices) {
1647 if (!serialNumber.equals(tmpDevice.serialNumber)) {
1648 LinkedBlockingQueue<QueueObject> tmpQueueObjects = devices
1649 .get(tmpDevice.serialNumber);
1650 if (tmpQueueObjects != null) {
1651 QueueObject tmpQueueObject = tmpQueueObjects.peek();
1652 Future<?> tmpFuture = tmpQueueObject.future;
1653 if (!queueObject.equals(tmpQueueObject)
1654 || (tmpFuture != null && !tmpFuture.isDone())) {
1658 serial = serial + tmpDevice.serialNumber + " ";
1663 queueObject.future = scheduler.submit(() -> queuedExecuteSequenceNode(queueObject));
1664 logger.debug("thread {} device {}", queueObject.hashCode(), serial);
1676 private void queuedExecuteSequenceNode(QueueObject queueObject) {
1677 JsonObject nodeToExecute = queueObject.nodeToExecute;
1678 ExecutionNodeObject executionNodeObject = getExecutionNodeObject(nodeToExecute);
1679 if (executionNodeObject == null) {
1680 logger.debug("executionNodeObject empty, removing without execution");
1681 removeObjectFromQueueAfterExecutionCompletion(queueObject);
1684 List<String> types = executionNodeObject.types;
1686 if (types.contains("Alexa.DeviceControls.Volume")) {
1689 if (types.contains("Announcement")) {
1695 JsonObject sequenceJson = new JsonObject();
1696 sequenceJson.addProperty("@type", "com.amazon.alexa.behaviors.model.Sequence");
1697 sequenceJson.add("startNode", nodeToExecute);
1699 JsonStartRoutineRequest request = new JsonStartRoutineRequest();
1700 request.sequenceJson = gson.toJson(sequenceJson);
1701 String json = gson.toJson(request);
1703 Map<String, String> headers = new HashMap<>();
1704 headers.put("Routines-Version", "1.1.218665");
1706 String text = executionNodeObject.text;
1708 text = text.replaceAll("<.+?>", " ").replaceAll("\\s+", " ").trim();
1709 delay += text.length() * 150;
1712 makeRequest("POST", alexaServer + "/api/behaviors/preview", json, true, true, null, 3);
1714 Thread.sleep(delay);
1715 } catch (IOException | URISyntaxException | InterruptedException e) {
1716 logger.warn("execute sequence node fails with unexpected error", e);
1718 removeObjectFromQueueAfterExecutionCompletion(queueObject);
1722 private void removeObjectFromQueueAfterExecutionCompletion(QueueObject queueObject) {
1724 for (Device device : queueObject.devices) {
1725 String serialNumber = device.serialNumber;
1726 if (serialNumber != null) {
1727 LinkedBlockingQueue<?> queue = devices.get(serialNumber);
1728 if (queue != null) {
1729 queue.remove(queueObject);
1731 serial = serial + serialNumber + " ";
1734 logger.debug("removed {} device {}", queueObject.hashCode(), serial);
1737 private void executeSequenceNodes(List<Device> devices, JsonArray nodesToExecute, boolean parallel) {
1738 JsonObject serialNode = new JsonObject();
1740 serialNode.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1742 serialNode.addProperty("@type", "com.amazon.alexa.behaviors.model.SerialNode");
1745 serialNode.add("nodesToExecute", nodesToExecute);
1747 executeSequenceNode(devices, serialNode);
1750 private JsonObject createExecutionNode(@Nullable Device device, String command, Map<String, Object> parameters) {
1751 JsonObject operationPayload = new JsonObject();
1752 if (device != null) {
1753 operationPayload.addProperty("deviceType", device.deviceType);
1754 operationPayload.addProperty("deviceSerialNumber", device.serialNumber);
1755 operationPayload.addProperty("locale", "");
1756 operationPayload.addProperty("customerId", getCustomerId(device.deviceOwnerCustomerId));
1758 for (String key : parameters.keySet()) {
1759 Object value = parameters.get(key);
1760 if (value instanceof String) {
1761 operationPayload.addProperty(key, (String) value);
1762 } else if (value instanceof Number) {
1763 operationPayload.addProperty(key, (Number) value);
1764 } else if (value instanceof Boolean) {
1765 operationPayload.addProperty(key, (Boolean) value);
1766 } else if (value instanceof Character) {
1767 operationPayload.addProperty(key, (Character) value);
1769 operationPayload.add(key, gson.toJsonTree(value));
1773 JsonObject nodeToExecute = new JsonObject();
1774 nodeToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode");
1775 nodeToExecute.addProperty("type", command);
1776 if ("Alexa.TextCommand".equals(command)) {
1777 nodeToExecute.addProperty("skillId", "amzn1.ask.1p.tellalexa");
1779 nodeToExecute.add("operationPayload", operationPayload);
1780 return nodeToExecute;
1784 private ExecutionNodeObject getExecutionNodeObject(JsonObject nodeToExecute) {
1785 ExecutionNodeObject executionNodeObject = new ExecutionNodeObject();
1786 if (nodeToExecute.has("nodesToExecute")) {
1787 JsonArray serialNodesToExecute = nodeToExecute.getAsJsonArray("nodesToExecute");
1788 if (serialNodesToExecute != null && serialNodesToExecute.size() > 0) {
1789 for (int i = 0; i < serialNodesToExecute.size(); i++) {
1790 JsonObject serialNodesToExecuteJsonObject = serialNodesToExecute.get(i).getAsJsonObject();
1791 if (serialNodesToExecuteJsonObject.has("nodesToExecute")) {
1792 JsonArray parallelNodesToExecute = serialNodesToExecuteJsonObject
1793 .getAsJsonArray("nodesToExecute");
1794 if (parallelNodesToExecute != null && parallelNodesToExecute.size() > 0) {
1795 JsonObject parallelNodesToExecuteJsonObject = parallelNodesToExecute.get(0)
1797 if (processNodesToExecuteJsonObject(executionNodeObject,
1798 parallelNodesToExecuteJsonObject)) {
1803 if (processNodesToExecuteJsonObject(executionNodeObject, serialNodesToExecuteJsonObject)) {
1811 return executionNodeObject;
1814 private boolean processNodesToExecuteJsonObject(ExecutionNodeObject executionNodeObject,
1815 JsonObject nodesToExecuteJsonObject) {
1816 if (nodesToExecuteJsonObject.has("type")) {
1817 executionNodeObject.types.add(nodesToExecuteJsonObject.get("type").getAsString());
1818 if (nodesToExecuteJsonObject.has("operationPayload")) {
1819 JsonObject operationPayload = nodesToExecuteJsonObject.getAsJsonObject("operationPayload");
1820 if (operationPayload != null) {
1821 if (operationPayload.has("textToSpeak")) {
1822 executionNodeObject.text = operationPayload.get("textToSpeak").getAsString();
1824 } else if (operationPayload.has("text")) {
1825 executionNodeObject.text = operationPayload.get("text").getAsString();
1827 } else if (operationPayload.has("content")) {
1828 JsonArray content = operationPayload.getAsJsonArray("content");
1829 if (content != null && content.size() > 0) {
1830 JsonObject contentJsonObject = content.get(0).getAsJsonObject();
1831 if (contentJsonObject.has("speak")) {
1832 JsonObject speak = contentJsonObject.getAsJsonObject("speak");
1833 if (speak != null && speak.has("value")) {
1834 executionNodeObject.text = speak.get("value").getAsString();
1846 public void startRoutine(Device device, String utterance)
1847 throws IOException, URISyntaxException, InterruptedException {
1848 JsonAutomation found = null;
1849 String deviceLocale = "";
1850 JsonAutomation[] routines = getRoutines();
1851 if (routines == null) {
1854 for (JsonAutomation routine : routines) {
1855 if (routine != null) {
1856 Trigger[] triggers = routine.triggers;
1857 if (triggers != null && routine.sequence != null) {
1858 for (JsonAutomation.Trigger trigger : triggers) {
1859 if (trigger == null) {
1862 Payload payload = trigger.payload;
1863 if (payload == null) {
1866 String payloadUtterance = payload.utterance;
1867 if (payloadUtterance != null && payloadUtterance.equalsIgnoreCase(utterance)) {
1869 deviceLocale = payload.locale;
1876 if (found != null) {
1877 String sequenceJson = gson.toJson(found.sequence);
1879 JsonStartRoutineRequest request = new JsonStartRoutineRequest();
1880 request.behaviorId = found.automationId;
1883 // "deviceType":"ALEXA_CURRENT_DEVICE_TYPE"
1884 String deviceType = "\"deviceType\":\"ALEXA_CURRENT_DEVICE_TYPE\"";
1885 String newDeviceType = "\"deviceType\":\"" + device.deviceType + "\"";
1886 sequenceJson = sequenceJson.replace(deviceType.subSequence(0, deviceType.length()),
1887 newDeviceType.subSequence(0, newDeviceType.length()));
1889 // "deviceSerialNumber":"ALEXA_CURRENT_DSN"
1890 String deviceSerial = "\"deviceSerialNumber\":\"ALEXA_CURRENT_DSN\"";
1891 String newDeviceSerial = "\"deviceSerialNumber\":\"" + device.serialNumber + "\"";
1892 sequenceJson = sequenceJson.replace(deviceSerial.subSequence(0, deviceSerial.length()),
1893 newDeviceSerial.subSequence(0, newDeviceSerial.length()));
1895 // "customerId": "ALEXA_CUSTOMER_ID"
1896 String customerId = "\"customerId\":\"ALEXA_CUSTOMER_ID\"";
1897 String newCustomerId = "\"customerId\":\"" + getCustomerId(device.deviceOwnerCustomerId) + "\"";
1898 sequenceJson = sequenceJson.replace(customerId.subSequence(0, customerId.length()),
1899 newCustomerId.subSequence(0, newCustomerId.length()));
1901 // "locale": "ALEXA_CURRENT_LOCALE"
1902 String locale = "\"locale\":\"ALEXA_CURRENT_LOCALE\"";
1903 String newlocale = deviceLocale != null && !deviceLocale.isEmpty() ? "\"locale\":\"" + deviceLocale + "\""
1904 : "\"locale\":null";
1905 sequenceJson = sequenceJson.replace(locale.subSequence(0, locale.length()),
1906 newlocale.subSequence(0, newlocale.length()));
1908 request.sequenceJson = sequenceJson;
1910 String requestJson = gson.toJson(request);
1911 makeRequest("POST", alexaServer + "/api/behaviors/preview", requestJson, true, true, null, 3);
1913 logger.warn("Routine {} not found", utterance);
1917 public @Nullable JsonAutomation @Nullable [] getRoutines()
1918 throws IOException, URISyntaxException, InterruptedException {
1919 String json = makeRequestAndReturnString(alexaServer + "/api/behaviors/automations?limit=2000");
1920 JsonAutomation[] result = parseJson(json, JsonAutomation[].class);
1924 public JsonFeed[] getEnabledFlashBriefings() throws IOException, URISyntaxException, InterruptedException {
1925 String json = makeRequestAndReturnString(alexaServer + "/api/content-skills/enabled-feeds");
1926 JsonEnabledFeeds result = parseJson(json, JsonEnabledFeeds.class);
1927 if (result == null) {
1928 return new JsonFeed[0];
1930 JsonFeed[] enabledFeeds = result.enabledFeeds;
1931 if (enabledFeeds != null) {
1932 return enabledFeeds;
1934 return new JsonFeed[0];
1937 public void setEnabledFlashBriefings(JsonFeed[] enabledFlashBriefing)
1938 throws IOException, URISyntaxException, InterruptedException {
1939 JsonEnabledFeeds enabled = new JsonEnabledFeeds();
1940 enabled.enabledFeeds = enabledFlashBriefing;
1941 String json = gsonWithNullSerialization.toJson(enabled);
1942 makeRequest("POST", alexaServer + "/api/content-skills/enabled-feeds", json, true, true, null, 0);
1945 public JsonNotificationSound[] getNotificationSounds(Device device)
1946 throws IOException, URISyntaxException, InterruptedException {
1947 String json = makeRequestAndReturnString(
1948 alexaServer + "/api/notification/sounds?deviceSerialNumber=" + device.serialNumber + "&deviceType="
1949 + device.deviceType + "&softwareVersion=" + device.softwareVersion);
1950 JsonNotificationSounds result = parseJson(json, JsonNotificationSounds.class);
1951 if (result == null) {
1952 return new JsonNotificationSound[0];
1954 JsonNotificationSound[] notificationSounds = result.notificationSounds;
1955 if (notificationSounds != null) {
1956 return notificationSounds;
1958 return new JsonNotificationSound[0];
1961 public JsonNotificationResponse[] notifications() throws IOException, URISyntaxException, InterruptedException {
1962 String response = makeRequestAndReturnString(alexaServer + "/api/notifications");
1963 JsonNotificationsResponse result = parseJson(response, JsonNotificationsResponse.class);
1964 if (result == null) {
1965 return new JsonNotificationResponse[0];
1967 JsonNotificationResponse[] notifications = result.notifications;
1968 if (notifications == null) {
1969 return new JsonNotificationResponse[0];
1971 return notifications;
1974 public @Nullable JsonNotificationResponse notification(Device device, String type, @Nullable String label,
1975 @Nullable JsonNotificationSound sound) throws IOException, URISyntaxException, InterruptedException {
1976 Date date = new Date(new Date().getTime());
1977 long createdDate = date.getTime();
1978 Date alarm = new Date(createdDate + 5000); // add 5 seconds, because amazon does not except calls for times in
1979 // the past (compared with the server time)
1980 long alarmTime = alarm.getTime();
1982 JsonNotificationRequest request = new JsonNotificationRequest();
1983 request.type = type;
1984 request.deviceSerialNumber = device.serialNumber;
1985 request.deviceType = device.deviceType;
1986 request.createdDate = createdDate;
1987 request.alarmTime = alarmTime;
1988 request.reminderLabel = label;
1989 request.sound = sound;
1990 request.originalDate = new SimpleDateFormat("yyyy-MM-dd").format(alarm);
1991 request.originalTime = new SimpleDateFormat("HH:mm:ss.SSSS").format(alarm);
1992 request.type = type;
1993 request.id = "create" + type;
1995 String data = gsonWithNullSerialization.toJson(request);
1996 String response = makeRequestAndReturnString("PUT", alexaServer + "/api/notifications/createReminder", data,
1998 JsonNotificationResponse result = parseJson(response, JsonNotificationResponse.class);
2002 public void stopNotification(JsonNotificationResponse notification)
2003 throws IOException, URISyntaxException, InterruptedException {
2004 makeRequestAndReturnString("DELETE", alexaServer + "/api/notifications/" + notification.id, null, true, null);
2007 public @Nullable JsonNotificationResponse getNotificationState(JsonNotificationResponse notification)
2008 throws IOException, URISyntaxException, InterruptedException {
2009 String response = makeRequestAndReturnString("GET", alexaServer + "/api/notifications/" + notification.id, null,
2011 JsonNotificationResponse result = parseJson(response, JsonNotificationResponse.class);
2015 public List<JsonMusicProvider> getMusicProviders() {
2017 Map<String, String> headers = new HashMap<>();
2018 headers.put("Routines-Version", "1.1.218665");
2019 String response = makeRequestAndReturnString("GET",
2020 alexaServer + "/api/behaviors/entities?skillId=amzn1.ask.1p.music", null, true, headers);
2021 if (!response.isEmpty()) {
2022 JsonMusicProvider[] result = parseJson(response, JsonMusicProvider[].class);
2023 return Arrays.asList(result);
2025 } catch (IOException | URISyntaxException | InterruptedException e) {
2026 logger.warn("getMusicProviders fails: {}", e.getMessage());
2031 public void playMusicVoiceCommand(Device device, String providerId, String voiceCommand)
2032 throws IOException, URISyntaxException, InterruptedException {
2033 JsonPlaySearchPhraseOperationPayload payload = new JsonPlaySearchPhraseOperationPayload();
2034 payload.customerId = getCustomerId(device.deviceOwnerCustomerId);
2035 payload.locale = "ALEXA_CURRENT_LOCALE";
2036 payload.musicProviderId = providerId;
2037 payload.searchPhrase = voiceCommand;
2039 String playloadString = gson.toJson(payload);
2041 JsonObject postValidationJson = new JsonObject();
2043 postValidationJson.addProperty("type", "Alexa.Music.PlaySearchPhrase");
2044 postValidationJson.addProperty("operationPayload", playloadString);
2046 String postDataValidate = postValidationJson.toString();
2048 String validateResultJson = makeRequestAndReturnString("POST",
2049 alexaServer + "/api/behaviors/operation/validate", postDataValidate, true, null);
2051 if (!validateResultJson.isEmpty()) {
2052 JsonPlayValidationResult validationResult = parseJson(validateResultJson, JsonPlayValidationResult.class);
2053 if (validationResult != null) {
2054 JsonPlaySearchPhraseOperationPayload validatedOperationPayload = validationResult.operationPayload;
2055 if (validatedOperationPayload != null) {
2056 payload.sanitizedSearchPhrase = validatedOperationPayload.sanitizedSearchPhrase;
2057 payload.searchPhrase = validatedOperationPayload.searchPhrase;
2062 payload.locale = null;
2063 payload.deviceSerialNumber = device.serialNumber;
2064 payload.deviceType = device.deviceType;
2066 JsonObject sequenceJson = new JsonObject();
2067 sequenceJson.addProperty("@type", "com.amazon.alexa.behaviors.model.Sequence");
2068 JsonObject startNodeJson = new JsonObject();
2069 startNodeJson.addProperty("@type", "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode");
2070 startNodeJson.addProperty("type", "Alexa.Music.PlaySearchPhrase");
2071 startNodeJson.add("operationPayload", gson.toJsonTree(payload));
2072 sequenceJson.add("startNode", startNodeJson);
2074 JsonStartRoutineRequest startRoutineRequest = new JsonStartRoutineRequest();
2075 startRoutineRequest.sequenceJson = sequenceJson.toString();
2076 startRoutineRequest.status = null;
2078 String postData = gson.toJson(startRoutineRequest);
2079 makeRequest("POST", alexaServer + "/api/behaviors/preview", postData, true, true, null, 3);
2082 public @Nullable JsonEqualizer getEqualizer(Device device)
2083 throws IOException, URISyntaxException, InterruptedException {
2084 String json = makeRequestAndReturnString(
2085 alexaServer + "/api/equalizer/" + device.serialNumber + "/" + device.deviceType);
2086 return parseJson(json, JsonEqualizer.class);
2089 public void setEqualizer(Device device, JsonEqualizer settings)
2090 throws IOException, URISyntaxException, InterruptedException {
2091 String postData = gson.toJson(settings);
2092 makeRequest("POST", alexaServer + "/api/equalizer/" + device.serialNumber + "/" + device.deviceType, postData,
2093 true, true, null, 0);
2096 public static class AnnouncementWrapper {
2097 public List<Device> devices = new ArrayList<>();
2098 public String speak;
2099 public String bodyText;
2100 public @Nullable String title;
2101 public List<@Nullable Integer> ttsVolumes = new ArrayList<>();
2102 public List<@Nullable Integer> standardVolumes = new ArrayList<>();
2104 public AnnouncementWrapper(String speak, String bodyText, @Nullable String title) {
2106 this.bodyText = bodyText;
2111 private static class TextToSpeech {
2112 public List<Device> devices = new ArrayList<>();
2114 public List<@Nullable Integer> ttsVolumes = new ArrayList<>();
2115 public List<@Nullable Integer> standardVolumes = new ArrayList<>();
2117 public TextToSpeech(String text) {
2122 private static class TextCommand {
2123 public List<Device> devices = new ArrayList<>();
2125 public List<@Nullable Integer> ttsVolumes = new ArrayList<>();
2126 public List<@Nullable Integer> standardVolumes = new ArrayList<>();
2128 public TextCommand(String text) {
2133 private static class Volume {
2134 public List<Device> devices = new ArrayList<>();
2136 public List<@Nullable Integer> volumes = new ArrayList<>();
2138 public Volume(int volume) {
2139 this.volume = volume;
2143 private static class QueueObject {
2144 public @Nullable Future<?> future;
2145 public List<Device> devices = List.of();
2146 public JsonObject nodeToExecute = new JsonObject();
2149 private static class ExecutionNodeObject {
2150 public List<String> types = new ArrayList<>();