2 * Copyright (c) 2010-2023 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 static org.openhab.binding.amazonechocontrol.internal.smarthome.Constants.*;
17 import java.io.IOException;
18 import java.io.InputStream;
19 import java.io.InterruptedIOException;
20 import java.io.OutputStream;
21 import java.net.CookieManager;
22 import java.net.CookieStore;
23 import java.net.HttpCookie;
25 import java.net.URISyntaxException;
27 import java.net.URLDecoder;
28 import java.net.URLEncoder;
29 import java.nio.charset.StandardCharsets;
30 import java.text.SimpleDateFormat;
31 import java.util.ArrayList;
32 import java.util.Arrays;
33 import java.util.Base64;
34 import java.util.Collections;
35 import java.util.Date;
36 import java.util.HashMap;
37 import java.util.HashSet;
38 import java.util.Iterator;
39 import java.util.LinkedHashMap;
40 import java.util.List;
42 import java.util.Map.Entry;
43 import java.util.Objects;
44 import java.util.Random;
45 import java.util.Scanner;
47 import java.util.concurrent.ConcurrentHashMap;
48 import java.util.concurrent.Future;
49 import java.util.concurrent.LinkedBlockingQueue;
50 import java.util.concurrent.ScheduledExecutorService;
51 import java.util.concurrent.ScheduledFuture;
52 import java.util.concurrent.TimeUnit;
53 import java.util.concurrent.locks.Lock;
54 import java.util.concurrent.locks.ReentrantLock;
55 import java.util.regex.Matcher;
56 import java.util.regex.Pattern;
57 import java.util.stream.Collectors;
58 import java.util.stream.StreamSupport;
59 import java.util.zip.GZIPInputStream;
61 import javax.net.ssl.HttpsURLConnection;
63 import org.eclipse.jdt.annotation.NonNullByDefault;
64 import org.eclipse.jdt.annotation.Nullable;
65 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonActivities;
66 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonActivities.Activity;
67 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAnnouncementContent;
68 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAnnouncementTarget;
69 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAscendingAlarm;
70 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAscendingAlarm.AscendingAlarmModel;
71 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAutomation;
72 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAutomation.Payload;
73 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBluetoothStates;
74 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBootstrapResult;
75 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBootstrapResult.Authentication;
76 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDeviceNotificationState;
77 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDeviceNotificationState.DeviceNotificationState;
78 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDevices;
79 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDevices.Device;
80 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonEnabledFeeds;
81 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonEqualizer;
82 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonExchangeTokenResponse;
83 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonExchangeTokenResponse.Cookie;
84 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonFeed;
85 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonMediaState;
86 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonMusicProvider;
87 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNetworkDetails;
88 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationRequest;
89 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationResponse;
90 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationSound;
91 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationSounds;
92 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationsResponse;
93 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlaySearchPhraseOperationPayload;
94 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlayValidationResult;
95 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlayerState;
96 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlaylists;
97 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppRequest;
98 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse;
99 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Bearer;
100 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.DeviceInfo;
101 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Extensions;
102 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Response;
103 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Success;
104 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Tokens;
105 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRenewTokenResponse;
106 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonSmartHomeDevices.SmartHomeDevice;
107 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonSmartHomeGroups.SmartHomeGroup;
108 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonStartRoutineRequest;
109 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonUsersMeResponse;
110 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonWakeWords;
111 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonWakeWords.WakeWord;
112 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonWebSiteCookie;
113 import org.openhab.binding.amazonechocontrol.internal.jsons.SmartHomeBaseDevice;
114 import org.openhab.core.common.ThreadPoolManager;
115 import org.openhab.core.library.types.QuantityType;
116 import org.openhab.core.library.types.StringType;
117 import org.openhab.core.library.unit.SIUnits;
118 import org.openhab.core.util.HexUtils;
119 import org.slf4j.Logger;
120 import org.slf4j.LoggerFactory;
122 import com.google.gson.Gson;
123 import com.google.gson.GsonBuilder;
124 import com.google.gson.JsonArray;
125 import com.google.gson.JsonElement;
126 import com.google.gson.JsonNull;
127 import com.google.gson.JsonObject;
128 import com.google.gson.JsonParseException;
129 import com.google.gson.JsonSyntaxException;
132 * The {@link Connection} is responsible for the connection to the amazon server
133 * and handling of the commands
135 * @author Michael Geramb - Initial contribution
138 public class Connection {
139 private static final String THING_THREADPOOL_NAME = "thingHandler";
140 private static final long EXPIRES_IN = 432000; // five days
141 private static final Pattern CHARSET_PATTERN = Pattern.compile("(?i)\\bcharset=\\s*\"?([^\\s;\"]*)");
142 private static final String DEVICE_TYPE = "A2IVLV5VM2W81";
144 private final Logger logger = LoggerFactory.getLogger(Connection.class);
146 protected final ScheduledExecutorService scheduler = ThreadPoolManager.getScheduledPool(THING_THREADPOOL_NAME);
148 private final Random rand = new Random();
149 private final CookieManager cookieManager = new CookieManager();
150 private final Gson gson;
151 private final Gson gsonWithNullSerialization;
153 private String amazonSite = "amazon.com";
154 private String alexaServer = "https://alexa.amazon.com";
155 private final String userAgent;
157 private String serial;
158 private String deviceId;
160 private @Nullable String refreshToken;
161 private @Nullable Date loginTime;
162 private @Nullable Date verifyTime;
163 private long renewTime = 0;
164 private @Nullable String deviceName;
165 private @Nullable String accountCustomerId;
166 private @Nullable String customerName;
168 private Map<Integer, AnnouncementWrapper> announcements = Collections.synchronizedMap(new LinkedHashMap<>());
169 private Map<Integer, TextToSpeech> textToSpeeches = Collections.synchronizedMap(new LinkedHashMap<>());
170 private Map<Integer, TextCommand> textCommands = Collections.synchronizedMap(new LinkedHashMap<>());
172 private Map<Integer, Volume> volumes = Collections.synchronizedMap(new LinkedHashMap<>());
173 private Map<String, LinkedBlockingQueue<QueueObject>> devices = Collections.synchronizedMap(new LinkedHashMap<>());
175 private final Map<TimerType, ScheduledFuture<?>> timers = new ConcurrentHashMap<>();
176 private final Map<TimerType, Lock> locks = new ConcurrentHashMap<>();
178 private enum TimerType {
186 public Connection(@Nullable Connection oldConnection, Gson gson) {
189 String serial = null;
190 String deviceId = null;
191 if (oldConnection != null) {
192 deviceId = oldConnection.getDeviceId();
193 frc = oldConnection.getFrc();
194 serial = oldConnection.getSerial();
200 byte[] frcBinary = new byte[313];
201 rand.nextBytes(frcBinary);
202 this.frc = Base64.getEncoder().encodeToString(frcBinary);
204 if (serial != null) {
205 this.serial = serial;
208 byte[] serialBinary = new byte[16];
209 rand.nextBytes(serialBinary);
210 this.serial = HexUtils.bytesToHex(serialBinary);
212 if (deviceId != null) {
213 this.deviceId = deviceId;
215 this.deviceId = generateDeviceId();
219 this.userAgent = "AmazonWebView/Amazon Alexa/2.2.223830.0/iOS/11.4.1/iPhone";
220 GsonBuilder gsonBuilder = new GsonBuilder();
221 gsonWithNullSerialization = gsonBuilder.create();
223 replaceTimer(TimerType.DEVICES,
224 scheduler.scheduleWithFixedDelay(this::handleExecuteSequenceNode, 0, 500, TimeUnit.MILLISECONDS));
228 * Generate a new device id
230 * The device id consists of 16 random bytes in upper-case hex format, a # as separator and a fixed DEVICE_TYPE
232 * @return a string containing the new device-id
234 private String generateDeviceId() {
235 byte[] bytes = new byte[16];
236 rand.nextBytes(bytes);
237 String hexStr = HexUtils.bytesToHex(bytes).toUpperCase() + "#" + DEVICE_TYPE;
238 return HexUtils.bytesToHex(hexStr.getBytes());
242 * Check if deviceId is valid (consisting of hex(hex(16 random bytes)) + "#" + DEVICE_TYPE)
244 * @param deviceId the deviceId
245 * @return true if valid, false if invalid
247 private boolean checkDeviceIdIsValid(@Nullable String deviceId) {
248 if (deviceId != null && deviceId.matches("^[0-9a-fA-F]{92}$")) {
249 String hexString = new String(HexUtils.hexToBytes(deviceId));
250 if (hexString.matches("^[0-9A-F]{32}#" + DEVICE_TYPE + "$")) {
257 private void setAmazonSite(@Nullable String amazonSite) {
258 String correctedAmazonSite = amazonSite != null ? amazonSite : "amazon.com";
259 if (correctedAmazonSite.toLowerCase().startsWith("http://")) {
260 correctedAmazonSite = correctedAmazonSite.substring(7);
262 if (correctedAmazonSite.toLowerCase().startsWith("https://")) {
263 correctedAmazonSite = correctedAmazonSite.substring(8);
265 if (correctedAmazonSite.toLowerCase().startsWith("www.")) {
266 correctedAmazonSite = correctedAmazonSite.substring(4);
268 if (correctedAmazonSite.toLowerCase().startsWith("alexa.")) {
269 correctedAmazonSite = correctedAmazonSite.substring(6);
271 this.amazonSite = correctedAmazonSite;
272 alexaServer = "https://alexa." + this.amazonSite;
275 public @Nullable Date tryGetLoginTime() {
279 public @Nullable Date tryGetVerifyTime() {
283 public String getFrc() {
287 public String getSerial() {
291 public String getDeviceId() {
295 public String getAmazonSite() {
299 public String getAlexaServer() {
303 public String getDeviceName() {
304 String deviceName = this.deviceName;
305 if (deviceName == null) {
311 public String getCustomerId() {
312 String customerId = this.accountCustomerId;
313 if (customerId == null) {
319 public String getCustomerName() {
320 String customerName = this.customerName;
321 if (customerName == null) {
327 public boolean isSequenceNodeQueueRunning() {
328 return devices.values().stream().anyMatch(
329 (queueObjects) -> (queueObjects.stream().anyMatch(queueObject -> queueObject.future != null)));
332 public String serializeLoginData() {
333 Date loginTime = this.loginTime;
334 if (refreshToken == null || loginTime == null) {
337 StringBuilder builder = new StringBuilder();
338 builder.append("7\n"); // version
340 builder.append("\n");
341 builder.append(serial);
342 builder.append("\n");
343 builder.append(deviceId);
344 builder.append("\n");
345 builder.append(refreshToken);
346 builder.append("\n");
347 builder.append(amazonSite);
348 builder.append("\n");
349 builder.append(deviceName);
350 builder.append("\n");
351 builder.append(accountCustomerId);
352 builder.append("\n");
353 builder.append(loginTime.getTime());
354 builder.append("\n");
355 List<HttpCookie> cookies = cookieManager.getCookieStore().getCookies();
356 builder.append(cookies.size());
357 builder.append("\n");
358 for (HttpCookie cookie : cookies) {
359 writeValue(builder, cookie.getName());
360 writeValue(builder, cookie.getValue());
361 writeValue(builder, cookie.getComment());
362 writeValue(builder, cookie.getCommentURL());
363 writeValue(builder, cookie.getDomain());
364 writeValue(builder, cookie.getMaxAge());
365 writeValue(builder, cookie.getPath());
366 writeValue(builder, cookie.getPortlist());
367 writeValue(builder, cookie.getVersion());
368 writeValue(builder, cookie.getSecure());
369 writeValue(builder, cookie.getDiscard());
371 return builder.toString();
374 private void writeValue(StringBuilder builder, @Nullable Object value) {
379 builder.append("\n");
380 builder.append(value.toString());
382 builder.append("\n");
385 private String readValue(Scanner scanner) {
386 if (scanner.nextLine().equals("1")) {
387 String result = scanner.nextLine();
388 if (result != null) {
395 public boolean tryRestoreLogin(@Nullable String data, @Nullable String overloadedDomain) {
396 Date loginTime = tryRestoreSessionData(data, overloadedDomain);
397 if (loginTime != null) {
400 this.loginTime = loginTime;
403 } catch (IOException e) {
405 } catch (URISyntaxException | InterruptedException e) {
411 private @Nullable Date tryRestoreSessionData(@Nullable String data, @Nullable String overloadedDomain) {
413 if (data == null || data.isEmpty()) {
416 Scanner scanner = new Scanner(data);
417 String version = scanner.nextLine();
418 // check if serialize version is supported
419 if (!"5".equals(version) && !"6".equals(version) && !"7".equals(version)) {
423 int intVersion = Integer.parseInt(version);
425 frc = scanner.nextLine();
426 serial = scanner.nextLine();
427 deviceId = scanner.nextLine();
429 // Recreate session and cookies
430 refreshToken = scanner.nextLine();
431 String domain = scanner.nextLine();
432 if (overloadedDomain != null) {
433 domain = overloadedDomain;
435 setAmazonSite(domain);
437 deviceName = scanner.nextLine();
439 if (intVersion > 5) {
440 String accountCustomerId = scanner.nextLine();
441 // Note: version 5 have wrong customer id serialized.
442 // Only use it, if it at least version 6 of serialization
443 if (intVersion > 6) {
444 if (!"null".equals(accountCustomerId)) {
445 this.accountCustomerId = accountCustomerId;
450 Date loginTime = new Date(Long.parseLong(scanner.nextLine()));
451 CookieStore cookieStore = cookieManager.getCookieStore();
452 cookieStore.removeAll();
454 Integer numberOfCookies = Integer.parseInt(scanner.nextLine());
455 for (Integer i = 0; i < numberOfCookies; i++) {
456 String name = readValue(scanner);
457 String value = readValue(scanner);
459 HttpCookie clientCookie = new HttpCookie(name, value);
460 clientCookie.setComment(readValue(scanner));
461 clientCookie.setCommentURL(readValue(scanner));
462 clientCookie.setDomain(readValue(scanner));
463 clientCookie.setMaxAge(Long.parseLong(readValue(scanner)));
464 clientCookie.setPath(readValue(scanner));
465 clientCookie.setPortlist(readValue(scanner));
466 clientCookie.setVersion(Integer.parseInt(readValue(scanner)));
467 clientCookie.setSecure(Boolean.parseBoolean(readValue(scanner)));
468 clientCookie.setDiscard(Boolean.parseBoolean(readValue(scanner)));
470 cookieStore.add(null, clientCookie);
476 String accountCustomerId = this.accountCustomerId;
477 if (accountCustomerId == null || accountCustomerId.isEmpty()) {
478 List<Device> devices = this.getDeviceList();
479 accountCustomerId = devices.stream().filter(device -> serial.equals(device.serialNumber)).findAny()
480 .map(device -> device.deviceOwnerCustomerId).orElse(null);
481 if (accountCustomerId == null || accountCustomerId.isEmpty()) {
482 accountCustomerId = devices.stream().filter(device -> "This Device".equals(device.accountName))
483 .findAny().map(device -> {
484 serial = Objects.requireNonNullElse(device.serialNumber, serial);
485 return device.deviceOwnerCustomerId;
488 this.accountCustomerId = accountCustomerId;
490 } catch (URISyntaxException | IOException | InterruptedException | ConnectionException e) {
491 logger.debug("Getting account customer Id failed", e);
496 private @Nullable Authentication tryGetBootstrap() throws IOException, URISyntaxException, InterruptedException {
497 HttpsURLConnection connection = makeRequest("GET", alexaServer + "/api/bootstrap", null, false, false, null, 0);
498 String contentType = connection.getContentType();
499 if (connection.getResponseCode() == 200 && contentType != null
500 && contentType.toLowerCase().startsWith("application/json")) {
502 String bootstrapResultJson = convertStream(connection);
503 JsonBootstrapResult result = parseJson(bootstrapResultJson, JsonBootstrapResult.class);
504 Authentication authentication = result.authentication;
505 if (authentication != null && authentication.authenticated) {
506 this.customerName = authentication.customerName;
507 if (this.accountCustomerId == null) {
508 this.accountCustomerId = authentication.customerId;
510 return authentication;
512 } catch (JsonSyntaxException | IllegalStateException e) {
513 logger.info("No valid json received", e);
520 public String convertStream(HttpsURLConnection connection) throws IOException {
521 InputStream input = connection.getInputStream();
526 InputStream readerStream;
527 if ("gzip".equalsIgnoreCase(connection.getContentEncoding())) {
528 readerStream = new GZIPInputStream(connection.getInputStream());
530 readerStream = input;
532 String contentType = connection.getContentType();
533 String charSet = null;
534 if (contentType != null) {
535 Matcher m = CHARSET_PATTERN.matcher(contentType);
537 charSet = m.group(1).trim().toUpperCase();
541 Scanner inputScanner = charSet == null || charSet.isEmpty()
542 ? new Scanner(readerStream, StandardCharsets.UTF_8.name())
543 : new Scanner(readerStream, charSet);
544 Scanner scannerWithoutDelimiter = inputScanner.useDelimiter("\\A");
545 String result = scannerWithoutDelimiter.hasNext() ? scannerWithoutDelimiter.next() : null;
546 inputScanner.close();
547 scannerWithoutDelimiter.close();
549 if (result == null) {
555 public String makeRequestAndReturnString(String url) throws IOException, URISyntaxException, InterruptedException {
556 return makeRequestAndReturnString("GET", url, null, false, null);
559 public String makeRequestAndReturnString(String verb, String url, @Nullable String postData, boolean json,
560 @Nullable Map<String, String> customHeaders) throws IOException, URISyntaxException, InterruptedException {
561 HttpsURLConnection connection = makeRequest(verb, url, postData, json, true, customHeaders, 3);
562 String result = convertStream(connection);
563 logger.debug("Result of {} {}:{}", verb, url, result);
567 public HttpsURLConnection makeRequest(String verb, String url, @Nullable String postData, boolean json,
568 boolean autoredirect, @Nullable Map<String, String> customHeaders, int badRequestRepeats)
569 throws IOException, URISyntaxException, InterruptedException {
570 String currentUrl = url;
571 int redirectCounter = 0;
572 int retryCounter = 0;
573 // loop for handling redirect and bad request, using automatic redirect is not
574 // possible, because all response headers must be catched
577 HttpsURLConnection connection = null;
579 logger.debug("Make request to {}", url);
580 connection = (HttpsURLConnection) new URL(currentUrl).openConnection();
581 connection.setRequestMethod(verb);
582 connection.setRequestProperty("Accept-Language", "en-US");
583 if (customHeaders == null || !customHeaders.containsKey("User-Agent")) {
584 connection.setRequestProperty("User-Agent", userAgent);
586 connection.setRequestProperty("Accept-Encoding", "gzip");
587 connection.setRequestProperty("DNT", "1");
588 connection.setRequestProperty("Upgrade-Insecure-Requests", "1");
589 if (customHeaders != null) {
590 for (String key : customHeaders.keySet()) {
591 String value = customHeaders.get(key);
592 if (value != null && !value.isEmpty()) {
593 connection.setRequestProperty(key, value);
597 connection.setInstanceFollowRedirects(false);
600 URI uri = connection.getURL().toURI();
602 if (customHeaders == null || !customHeaders.containsKey("Cookie")) {
603 StringBuilder cookieHeaderBuilder = new StringBuilder();
604 for (HttpCookie cookie : cookieManager.getCookieStore().get(uri)) {
605 if (cookieHeaderBuilder.length() > 0) {
606 cookieHeaderBuilder.append(";");
608 cookieHeaderBuilder.append(cookie.getName());
609 cookieHeaderBuilder.append("=");
610 cookieHeaderBuilder.append(cookie.getValue());
611 if (cookie.getName().equals("csrf")) {
612 connection.setRequestProperty("csrf", cookie.getValue());
616 if (cookieHeaderBuilder.length() > 0) {
617 String cookies = cookieHeaderBuilder.toString();
618 connection.setRequestProperty("Cookie", cookies);
621 if (postData != null) {
622 logger.debug("{}: {}", verb, postData);
624 byte[] postDataBytes = postData.getBytes(StandardCharsets.UTF_8);
625 int postDataLength = postDataBytes.length;
627 connection.setFixedLengthStreamingMode(postDataLength);
630 connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
632 connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
634 connection.setRequestProperty("Content-Length", Integer.toString(postDataLength));
635 if ("POST".equals(verb)) {
636 connection.setRequestProperty("Expect", "100-continue");
639 connection.setDoOutput(true);
640 OutputStream outputStream = connection.getOutputStream();
641 outputStream.write(postDataBytes);
642 outputStream.close();
645 code = connection.getResponseCode();
646 String location = null;
648 // handle response headers
649 Map<@Nullable String, List<String>> headerFields = connection.getHeaderFields();
650 for (Map.Entry<@Nullable String, List<String>> header : headerFields.entrySet()) {
651 String key = header.getKey();
652 if (key != null && !key.isEmpty()) {
653 if ("Set-Cookie".equalsIgnoreCase(key)) {
655 for (String cookieHeader : header.getValue()) {
656 if (!cookieHeader.isEmpty()) {
657 List<HttpCookie> cookies = HttpCookie.parse(cookieHeader);
658 for (HttpCookie cookie : cookies) {
659 cookieManager.getCookieStore().add(uri, cookie);
664 if ("Location".equalsIgnoreCase(key)) {
665 // get redirect location
666 location = header.getValue().get(0);
667 if (!location.isEmpty()) {
668 location = uri.resolve(location).toString();
670 if (location.toLowerCase().startsWith("http://")) {
672 location = "https://" + location.substring(7);
673 logger.debug("Redirect corrected to {}", location);
680 logger.debug("Call to {} succeeded", url);
682 } else if (code == 302 && location != null) {
683 logger.debug("Redirected to {}", location);
685 if (redirectCounter > 30) {
686 throw new ConnectionException("Too many redirects");
688 currentUrl = location;
690 continue; // repeat with new location
694 logger.debug("Retry call to {}", url);
696 if (retryCounter > badRequestRepeats) {
697 throw new HttpException(code,
698 verb + " url '" + url + "' failed: " + connection.getResponseMessage());
702 } catch (InterruptedException | InterruptedIOException e) {
703 if (connection != null) {
704 connection.disconnect();
706 logger.warn("Unable to wait for next call to {}", url, e);
708 } catch (IOException e) {
709 if (connection != null) {
710 connection.disconnect();
712 logger.warn("Request to url '{}' fails with unknown error", url, e);
714 } catch (Exception e) {
715 if (connection != null) {
716 connection.disconnect();
723 public String registerConnectionAsApp(String oAutRedirectUrl)
724 throws ConnectionException, IOException, URISyntaxException, InterruptedException {
725 URI oAutRedirectUri = new URI(oAutRedirectUrl);
727 Map<String, String> queryParameters = new LinkedHashMap<>();
728 String query = oAutRedirectUri.getQuery();
729 String[] pairs = query.split("&");
730 for (String pair : pairs) {
731 int idx = pair.indexOf("=");
732 queryParameters.put(URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8.name()),
733 URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8.name()));
735 String accessToken = queryParameters.get("openid.oa2.access_token");
737 Map<String, String> cookieMap = new HashMap<>();
739 List<JsonWebSiteCookie> webSiteCookies = new ArrayList<>();
740 for (HttpCookie cookie : getSessionCookies("https://www.amazon.com")) {
741 cookieMap.put(cookie.getName(), cookie.getValue());
742 webSiteCookies.add(new JsonWebSiteCookie(cookie.getName(), cookie.getValue()));
745 JsonRegisterAppRequest registerAppRequest = new JsonRegisterAppRequest(serial, accessToken, frc,
747 String registerAppRequestJson = gson.toJson(registerAppRequest);
749 HashMap<String, String> registerHeaders = new HashMap<>();
750 registerHeaders.put("x-amzn-identity-auth-domain", "api.amazon.com");
752 String registerAppResultJson = makeRequestAndReturnString("POST", "https://api.amazon.com/auth/register",
753 registerAppRequestJson, true, registerHeaders);
754 JsonRegisterAppResponse registerAppResponse = parseJson(registerAppResultJson, JsonRegisterAppResponse.class);
756 Response response = registerAppResponse.response;
757 if (response == null) {
758 throw new ConnectionException("Error: No response received from register application");
760 Success success = response.success;
761 if (success == null) {
762 throw new ConnectionException("Error: No success received from register application");
764 Tokens tokens = success.tokens;
765 if (tokens == null) {
766 throw new ConnectionException("Error: No tokens received from register application");
768 Bearer bearer = tokens.bearer;
769 if (bearer == null) {
770 throw new ConnectionException("Error: No bearer received from register application");
772 String refreshToken = bearer.refreshToken;
773 this.refreshToken = refreshToken;
774 if (refreshToken == null || refreshToken.isEmpty()) {
775 throw new ConnectionException("Error: No refresh token received");
779 // Check which is the owner domain
780 String usersMeResponseJson = makeRequestAndReturnString("GET",
781 "https://alexa.amazon.com/api/users/me?platform=ios&version=2.2.223830.0", null, false, null);
782 JsonUsersMeResponse usersMeResponse = parseJson(usersMeResponseJson, JsonUsersMeResponse.class);
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, InterruptedException {
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 = Objects
825 .requireNonNull(gson.fromJson(exchangeTokenJson, 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 if (cookies != null) {
836 for (Cookie cookie : cookies) {
837 if (cookie != null) {
838 HttpCookie httpCookie = new HttpCookie(cookie.name, cookie.value);
839 httpCookie.setPath(cookie.path);
840 httpCookie.setDomain(domain);
841 Boolean secure = cookie.secure;
842 if (secure != null) {
843 httpCookie.setSecure(secure);
845 this.cookieManager.getCookieStore().add(null, httpCookie);
853 if (!verifyLogin()) {
854 throw new ConnectionException("Verify login failed after token exchange");
856 this.renewTime = (long) (System.currentTimeMillis() + Connection.EXPIRES_IN * 1000d / 0.8d); // start renew at
859 public boolean checkRenewSession() throws URISyntaxException, IOException, InterruptedException {
860 if (System.currentTimeMillis() >= this.renewTime) {
861 String renewTokenPostData = "app_name=Amazon%20Alexa&app_version=2.2.223830.0&di.sdk.version=6.10.0&source_token="
862 + URLEncoder.encode(refreshToken, StandardCharsets.UTF_8.name())
863 + "&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";
864 String renewTokenResponseJson = makeRequestAndReturnString("POST", "https://api.amazon.com/auth/token",
865 renewTokenPostData, false, null);
866 parseJson(renewTokenResponseJson, JsonRenewTokenResponse.class);
874 public boolean getIsLoggedIn() {
875 return loginTime != null;
878 public String getLoginPage() throws IOException, URISyntaxException, InterruptedException {
879 // clear session data
882 logger.debug("Start Login to {}", alexaServer);
884 if (!checkDeviceIdIsValid(deviceId)) {
885 deviceId = generateDeviceId();
886 logger.debug("Generating new device id (old device id had invalid format).");
889 String mapMdJson = "{\"device_user_dictionary\":[],\"device_registration_data\":{\"software_version\":\"1\"},\"app_identifier\":{\"app_version\":\"2.2.223830\",\"bundle_id\":\"com.amazon.echo\"}}";
890 String mapMdCookie = Base64.getEncoder().encodeToString(mapMdJson.getBytes());
892 cookieManager.getCookieStore().add(new URI("https://www.amazon.com"), new HttpCookie("map-md", mapMdCookie));
893 cookieManager.getCookieStore().add(new URI("https://www.amazon.com"), new HttpCookie("frc", frc));
895 Map<String, String> customHeaders = new HashMap<>();
896 customHeaders.put("authority", "www.amazon.com");
897 String loginFormHtml = makeRequestAndReturnString("GET", "https://www.amazon.com"
898 + "/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:"
900 + "&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",
901 null, false, customHeaders);
903 logger.debug("Received login form {}", loginFormHtml);
904 return loginFormHtml;
907 public boolean verifyLogin() throws IOException, URISyntaxException, InterruptedException {
908 if (this.refreshToken == null) {
911 Authentication authentication = tryGetBootstrap();
912 if (authentication != null && authentication.authenticated) {
913 verifyTime = new Date();
914 if (loginTime == null) {
915 loginTime = verifyTime;
922 public List<HttpCookie> getSessionCookies() {
924 return cookieManager.getCookieStore().get(new URI(alexaServer));
925 } catch (URISyntaxException e) {
926 return new ArrayList<>();
930 public List<HttpCookie> getSessionCookies(String server) {
932 return cookieManager.getCookieStore().get(new URI(server));
933 } catch (URISyntaxException e) {
934 return new ArrayList<>();
938 @SuppressWarnings("null") // current value in compute can be null
939 private void replaceTimer(TimerType type, @Nullable ScheduledFuture<?> newTimer) {
940 timers.compute(type, (timerType, oldTimer) -> {
941 if (oldTimer != null) {
942 oldTimer.cancel(true);
948 public void logout() {
949 cookieManager.getCookieStore().removeAll();
956 replaceTimer(TimerType.ANNOUNCEMENT, null);
957 announcements.clear();
958 replaceTimer(TimerType.TTS, null);
959 textToSpeeches.clear();
960 replaceTimer(TimerType.VOLUME, null);
962 replaceTimer(TimerType.DEVICES, null);
963 textCommands.clear();
964 replaceTimer(TimerType.TTS, null);
966 devices.values().forEach((queueObjects) -> {
967 queueObjects.forEach((queueObject) -> {
968 Future<?> future = queueObject.future;
969 if (future != null) {
971 queueObject.future = null;
978 private <T> T parseJson(String json, Class<T> type) throws JsonSyntaxException, IllegalStateException {
980 // gson.fromJson is always non-null if json is non-null
981 return Objects.requireNonNull(gson.fromJson(json, type));
982 } catch (JsonParseException | IllegalStateException e) {
983 logger.warn("Parsing json failed: {}", json, e);
988 // commands and states
989 public List<WakeWord> getWakeWords() {
992 json = makeRequestAndReturnString(alexaServer + "/api/wake-word?cached=true");
993 JsonWakeWords wakeWords = parseJson(json, JsonWakeWords.class);
994 return Objects.requireNonNullElse(wakeWords.wakeWords, List.of());
995 } catch (IOException | URISyntaxException | InterruptedException e) {
996 logger.info("getting wakewords failed", e);
1001 public List<SmartHomeBaseDevice> getSmarthomeDeviceList()
1002 throws IOException, URISyntaxException, InterruptedException {
1004 String json = makeRequestAndReturnString(alexaServer + "/api/phoenix");
1005 logger.debug("getSmartHomeDevices result: {}", json);
1007 JsonNetworkDetails networkDetails = parseJson(json, JsonNetworkDetails.class);
1008 Object jsonObject = gson.fromJson(networkDetails.networkDetail, Object.class);
1009 List<SmartHomeBaseDevice> result = new ArrayList<>();
1010 searchSmartHomeDevicesRecursive(jsonObject, result);
1013 } catch (Exception e) {
1014 logger.warn("getSmartHomeDevices fails: {}", e.getMessage());
1019 private void searchSmartHomeDevicesRecursive(@Nullable Object jsonNode, List<SmartHomeBaseDevice> devices) {
1020 if (jsonNode instanceof Map) {
1021 @SuppressWarnings("rawtypes")
1022 Map<String, Object> map = (Map) jsonNode;
1023 if (map.containsKey("entityId") && map.containsKey("friendlyName") && map.containsKey("actions")) {
1024 // device node found, create type element and add it to the results
1025 JsonElement element = gson.toJsonTree(jsonNode);
1026 SmartHomeDevice shd = parseJson(element.toString(), SmartHomeDevice.class);
1028 } else if (map.containsKey("applianceGroupName")) {
1029 JsonElement element = gson.toJsonTree(jsonNode);
1030 SmartHomeGroup shg = parseJson(element.toString(), SmartHomeGroup.class);
1033 map.values().forEach(value -> searchSmartHomeDevicesRecursive(value, devices));
1038 public List<Device> getDeviceList() throws IOException, URISyntaxException, InterruptedException {
1039 JsonDevices devices = Objects.requireNonNull(parseJson(getDeviceListJson(), JsonDevices.class));
1040 logger.trace("Devices {}", devices.devices);
1042 // @Nullable because of a limitation of the null-checker, we filter null-serialNumbers before
1043 Set<@Nullable String> serialNumbers = ConcurrentHashMap.newKeySet();
1044 return devices.devices.stream().filter(d -> d.serialNumber != null && serialNumbers.add(d.serialNumber))
1045 .collect(Collectors.toList());
1048 public String getDeviceListJson() throws IOException, URISyntaxException, InterruptedException {
1049 String json = makeRequestAndReturnString(alexaServer + "/api/devices-v2/device?cached=false");
1053 public Map<String, JsonArray> getSmartHomeDeviceStatesJson(Set<SmartHomeBaseDevice> devices)
1054 throws IOException, URISyntaxException, InterruptedException {
1055 JsonObject requestObject = new JsonObject();
1056 JsonArray stateRequests = new JsonArray();
1057 Map<String, String> mergedApplianceMap = new HashMap<>();
1058 for (SmartHomeBaseDevice device : devices) {
1059 String applianceId = device.findId();
1060 if (applianceId != null) {
1061 JsonObject stateRequest;
1062 if (device instanceof SmartHomeDevice && ((SmartHomeDevice) device).mergedApplianceIds != null) {
1063 List<String> mergedApplianceIds = Objects
1064 .requireNonNullElse(((SmartHomeDevice) device).mergedApplianceIds, List.of());
1065 for (String idToMerge : mergedApplianceIds) {
1066 mergedApplianceMap.put(idToMerge, applianceId);
1067 stateRequest = new JsonObject();
1068 stateRequest.addProperty("entityId", idToMerge);
1069 stateRequest.addProperty("entityType", "APPLIANCE");
1070 stateRequests.add(stateRequest);
1073 stateRequest = new JsonObject();
1074 stateRequest.addProperty("entityId", applianceId);
1075 stateRequest.addProperty("entityType", "APPLIANCE");
1076 stateRequests.add(stateRequest);
1080 requestObject.add("stateRequests", stateRequests);
1081 String requestBody = requestObject.toString();
1082 String json = makeRequestAndReturnString("POST", alexaServer + "/api/phoenix/state", requestBody, true, null);
1083 logger.debug("Requested {} and received {}", requestBody, json);
1085 JsonObject responseObject = Objects.requireNonNull(gson.fromJson(json, JsonObject.class));
1086 JsonArray deviceStates = (JsonArray) responseObject.get("deviceStates");
1087 Map<String, JsonArray> result = new HashMap<>();
1088 for (JsonElement deviceState : deviceStates) {
1089 JsonObject deviceStateObject = deviceState.getAsJsonObject();
1090 JsonObject entity = deviceStateObject.get("entity").getAsJsonObject();
1091 String applianceId = entity.get("entityId").getAsString();
1092 JsonElement capabilityState = deviceStateObject.get("capabilityStates");
1093 if (capabilityState != null && capabilityState.isJsonArray()) {
1094 String realApplianceId = mergedApplianceMap.get(applianceId);
1095 if (realApplianceId != null) {
1096 var capabilityArray = result.get(realApplianceId);
1097 if (capabilityArray != null) {
1098 capabilityArray.addAll(capabilityState.getAsJsonArray());
1099 result.put(realApplianceId, capabilityArray);
1101 result.put(realApplianceId, capabilityState.getAsJsonArray());
1104 result.put(applianceId, capabilityState.getAsJsonArray());
1111 public @Nullable JsonPlayerState getPlayer(Device device)
1112 throws IOException, URISyntaxException, InterruptedException {
1113 String json = makeRequestAndReturnString(alexaServer + "/api/np/player?deviceSerialNumber="
1114 + device.serialNumber + "&deviceType=" + device.deviceType + "&screenWidth=1440");
1115 JsonPlayerState playerState = parseJson(json, JsonPlayerState.class);
1119 public @Nullable JsonMediaState getMediaState(Device device)
1120 throws IOException, URISyntaxException, InterruptedException {
1121 String json = makeRequestAndReturnString(alexaServer + "/api/media/state?deviceSerialNumber="
1122 + device.serialNumber + "&deviceType=" + device.deviceType);
1123 JsonMediaState mediaState = parseJson(json, JsonMediaState.class);
1127 public List<Activity> getActivities(int number, @Nullable Long startTime) {
1129 String json = makeRequestAndReturnString(alexaServer + "/api/activities?startTime="
1130 + (startTime != null ? startTime : "") + "&size=" + number + "&offset=1");
1131 JsonActivities activities = parseJson(json, JsonActivities.class);
1132 return Objects.requireNonNullElse(activities.activities, List.of());
1133 } catch (IOException | URISyntaxException | InterruptedException e) {
1134 logger.info("getting activities failed", e);
1139 public @Nullable JsonBluetoothStates getBluetoothConnectionStates() {
1142 json = makeRequestAndReturnString(alexaServer + "/api/bluetooth?cached=true");
1143 } catch (IOException | URISyntaxException | InterruptedException e) {
1144 logger.debug("failed to get bluetooth state: {}", e.getMessage());
1145 return new JsonBluetoothStates();
1147 JsonBluetoothStates bluetoothStates = parseJson(json, JsonBluetoothStates.class);
1148 return bluetoothStates;
1151 public @Nullable JsonPlaylists getPlaylists(Device device)
1152 throws IOException, URISyntaxException, InterruptedException {
1153 String json = makeRequestAndReturnString(
1154 alexaServer + "/api/cloudplayer/playlists?deviceSerialNumber=" + device.serialNumber + "&deviceType="
1155 + device.deviceType + "&mediaOwnerCustomerId=" + getCustomerId(device.deviceOwnerCustomerId));
1156 JsonPlaylists playlists = parseJson(json, JsonPlaylists.class);
1160 public void command(Device device, String command) throws IOException, URISyntaxException, InterruptedException {
1161 String url = alexaServer + "/api/np/command?deviceSerialNumber=" + device.serialNumber + "&deviceType="
1162 + device.deviceType;
1163 makeRequest("POST", url, command, true, true, null, 0);
1166 public void smartHomeCommand(String entityId, String action) throws IOException, InterruptedException {
1167 smartHomeCommand(entityId, action, null, null);
1170 public void smartHomeCommand(String entityId, String action, @Nullable String property, @Nullable Object value)
1171 throws IOException, InterruptedException {
1172 String url = alexaServer + "/api/phoenix/state";
1173 Float lowerSetpoint = null;
1174 Float upperSetpoint = null;
1176 JsonObject json = new JsonObject();
1177 JsonArray controlRequests = new JsonArray();
1178 JsonObject controlRequest = new JsonObject();
1179 controlRequest.addProperty("entityId", entityId);
1180 controlRequest.addProperty("entityType", "APPLIANCE");
1181 JsonObject parameters = new JsonObject();
1182 parameters.addProperty("action", action);
1183 if (property != null) {
1184 if ("setThermostatMode".equals(action)) {
1185 if (value instanceof StringType) {
1186 parameters.addProperty(property + ".value", value.toString());
1188 } else if ("setTargetTemperature".equals(action)) {
1189 if ("targetTemperature".equals(property)) {
1190 if (value instanceof QuantityType<?>) {
1191 parameters.addProperty(property + ".value", ((QuantityType<?>) value).floatValue());
1192 parameters.addProperty(property + ".scale",
1193 ((QuantityType<?>) value).getUnit().equals(SIUnits.CELSIUS) ? "celsius" : "fahrenheit");
1196 // Get current upper and lower setpoints to build command syntax
1197 Map<String, JsonArray> devices = null;
1199 List<SmartHomeBaseDevice> deviceList = getSmarthomeDeviceList().stream()
1200 .filter(device -> entityId.equals(device.findEntityId())).collect(Collectors.toList());
1201 devices = getSmartHomeDeviceStatesJson(new HashSet<>(deviceList));
1202 } catch (URISyntaxException e) {
1203 logger.debug("{}", e.toString());
1205 Entry<String, JsonArray> entry = devices.entrySet().iterator().next();
1206 JsonArray states = entry.getValue();
1207 for (JsonElement stateElement : states) {
1208 JsonObject stateValue = new JsonObject();
1209 String stateJson = stateElement.getAsString();
1210 if (stateJson.startsWith("{") && stateJson.endsWith("}")) {
1211 JsonObject state = Objects.requireNonNull(gson.fromJson(stateJson, JsonObject.class));
1212 String interfaceName = Objects.requireNonNullElse(state.get("namespace"), JsonNull.INSTANCE)
1214 String name = Objects.requireNonNullElse(state.get("name"), JsonNull.INSTANCE)
1216 if ("Alexa.ThermostatController".equals(interfaceName)) {
1217 if ("upperSetpoint".equals(name)) {
1218 stateValue = Objects.requireNonNullElse(state.get("value"), JsonNull.INSTANCE)
1220 upperSetpoint = Objects
1221 .requireNonNullElse(stateValue.get("value"), JsonNull.INSTANCE)
1223 } else if ("lowerSetpoint".equals(name)) {
1224 stateValue = Objects.requireNonNullElse(state.get("value"), JsonNull.INSTANCE)
1226 lowerSetpoint = Objects
1227 .requireNonNullElse(stateValue.get("value"), JsonNull.INSTANCE)
1233 if ("lowerSetTemperature".equals(property)) {
1234 if (value instanceof QuantityType<?>) {
1235 parameters.addProperty("upperSetTemperature.value", upperSetpoint);
1236 parameters.addProperty("upperSetTemperature.scale",
1237 ((QuantityType<?>) value).getUnit().equals(SIUnits.CELSIUS) ? "celsius"
1239 parameters.addProperty(property + ".value", ((QuantityType<?>) value).floatValue());
1240 parameters.addProperty(property + ".scale",
1241 ((QuantityType<?>) value).getUnit().equals(SIUnits.CELSIUS) ? "celsius"
1244 } else if ("upperSetTemperature".equals(property)) {
1245 if (value instanceof QuantityType<?>) {
1246 parameters.addProperty(property + ".value", ((QuantityType<?>) value).floatValue());
1247 parameters.addProperty(property + ".scale",
1248 ((QuantityType<?>) value).getUnit().equals(SIUnits.CELSIUS) ? "celsius"
1250 parameters.addProperty("lowerSetTemperature.value", lowerSetpoint);
1251 parameters.addProperty("lowerSetTemperature.scale",
1252 ((QuantityType<?>) value).getUnit().equals(SIUnits.CELSIUS) ? "celsius"
1258 if (value instanceof QuantityType<?>) {
1259 parameters.addProperty(property + ".value", ((QuantityType<?>) value).floatValue());
1260 parameters.addProperty(property + ".scale",
1261 ((QuantityType<?>) value).getUnit().equals(SIUnits.CELSIUS) ? "celsius" : "fahrenheit");
1262 } else if (value instanceof Boolean) {
1263 parameters.addProperty(property, (boolean) value);
1264 } else if (value instanceof String) {
1265 parameters.addProperty(property, (String) value);
1266 } else if (value instanceof Number) {
1267 parameters.addProperty(property, (Number) value);
1268 } else if (value instanceof Character) {
1269 parameters.addProperty(property, (Character) value);
1270 } else if (value instanceof JsonElement) {
1271 parameters.add(property, (JsonElement) value);
1275 controlRequest.add("parameters", parameters);
1276 controlRequests.add(controlRequest);
1277 json.add("controlRequests", controlRequests);
1279 String requestBody = json.toString();
1281 String resultBody = makeRequestAndReturnString("PUT", url, requestBody, true, null);
1282 logger.trace("Request '{}' resulted in '{}", requestBody, resultBody);
1283 JsonObject result = parseJson(resultBody, JsonObject.class);
1284 JsonElement errors = result.get("errors");
1285 if (errors != null && errors.isJsonArray()) {
1286 JsonArray errorList = errors.getAsJsonArray();
1287 if (errorList.size() > 0) {
1288 logger.warn("Smart home device command failed. The request '{}' resulted in error(s): {}",
1289 requestBody, StreamSupport.stream(errorList.spliterator(), false).map(JsonElement::toString)
1290 .collect(Collectors.joining(" / ")));
1293 } catch (URISyntaxException e) {
1294 logger.warn("URL '{}' has invalid format for request '{}': {}", url, requestBody, e.getMessage());
1298 public void notificationVolume(Device device, int volume)
1299 throws IOException, URISyntaxException, InterruptedException {
1300 String url = alexaServer + "/api/device-notification-state/" + device.deviceType + "/" + device.softwareVersion
1301 + "/" + device.serialNumber;
1302 String command = "{\"deviceSerialNumber\":\"" + device.serialNumber + "\",\"deviceType\":\"" + device.deviceType
1303 + "\",\"softwareVersion\":\"" + device.softwareVersion + "\",\"volumeLevel\":" + volume + "}";
1304 makeRequest("PUT", url, command, true, true, null, 0);
1307 public void ascendingAlarm(Device device, boolean ascendingAlarm)
1308 throws IOException, URISyntaxException, InterruptedException {
1309 String url = alexaServer + "/api/ascending-alarm/" + device.serialNumber;
1310 String command = "{\"ascendingAlarmEnabled\":" + (ascendingAlarm ? "true" : "false")
1311 + ",\"deviceSerialNumber\":\"" + device.serialNumber + "\",\"deviceType\":\"" + device.deviceType
1312 + "\",\"deviceAccountId\":null}";
1313 makeRequest("PUT", url, command, true, true, null, 0);
1316 public List<DeviceNotificationState> getDeviceNotificationStates() {
1318 String json = makeRequestAndReturnString(alexaServer + "/api/device-notification-state");
1319 JsonDeviceNotificationState result = parseJson(json, JsonDeviceNotificationState.class);
1320 return Objects.requireNonNullElse(result.deviceNotificationStates, List.of());
1321 } catch (IOException | URISyntaxException | InterruptedException e) {
1322 logger.info("Error getting device notification states", e);
1327 public List<AscendingAlarmModel> getAscendingAlarm() {
1330 json = makeRequestAndReturnString(alexaServer + "/api/ascending-alarm");
1331 JsonAscendingAlarm result = parseJson(json, JsonAscendingAlarm.class);
1332 return Objects.requireNonNullElse(result.ascendingAlarmModelList, List.of());
1333 } catch (IOException | URISyntaxException | InterruptedException e) {
1334 logger.info("Error getting device notification states", e);
1339 public void bluetooth(Device device, @Nullable String address)
1340 throws IOException, URISyntaxException, InterruptedException {
1341 if (address == null || address.isEmpty()) {
1344 alexaServer + "/api/bluetooth/disconnect-sink/" + device.deviceType + "/" + device.serialNumber, "",
1345 true, true, null, 0);
1348 alexaServer + "/api/bluetooth/pair-sink/" + device.deviceType + "/" + device.serialNumber,
1349 "{\"bluetoothDeviceAddress\":\"" + address + "\"}", true, true, null, 0);
1353 private @Nullable String getCustomerId(@Nullable String defaultId) {
1354 String accountCustomerId = this.accountCustomerId;
1355 return accountCustomerId == null || accountCustomerId.isEmpty() ? defaultId : accountCustomerId;
1358 public void playRadio(Device device, @Nullable String stationId)
1359 throws IOException, URISyntaxException, InterruptedException {
1360 if (stationId == null || stationId.isEmpty()) {
1361 command(device, "{\"type\":\"PauseCommand\"}");
1364 alexaServer + "/api/tunein/queue-and-play?deviceSerialNumber=" + device.serialNumber
1365 + "&deviceType=" + device.deviceType + "&guideId=" + stationId
1366 + "&contentType=station&callSign=&mediaOwnerCustomerId="
1367 + getCustomerId(device.deviceOwnerCustomerId),
1368 "", true, true, null, 0);
1372 public void playAmazonMusicTrack(Device device, @Nullable String trackId)
1373 throws IOException, URISyntaxException, InterruptedException {
1374 if (trackId == null || trackId.isEmpty()) {
1375 command(device, "{\"type\":\"PauseCommand\"}");
1377 String command = "{\"trackId\":\"" + trackId + "\",\"playQueuePrime\":true}";
1379 alexaServer + "/api/cloudplayer/queue-and-play?deviceSerialNumber=" + device.serialNumber
1380 + "&deviceType=" + device.deviceType + "&mediaOwnerCustomerId="
1381 + getCustomerId(device.deviceOwnerCustomerId) + "&shuffle=false",
1382 command, true, true, null, 0);
1386 public void playAmazonMusicPlayList(Device device, @Nullable String playListId)
1387 throws IOException, URISyntaxException, InterruptedException {
1388 if (playListId == null || playListId.isEmpty()) {
1389 command(device, "{\"type\":\"PauseCommand\"}");
1391 String command = "{\"playlistId\":\"" + playListId + "\",\"playQueuePrime\":true}";
1393 alexaServer + "/api/cloudplayer/queue-and-play?deviceSerialNumber=" + device.serialNumber
1394 + "&deviceType=" + device.deviceType + "&mediaOwnerCustomerId="
1395 + getCustomerId(device.deviceOwnerCustomerId) + "&shuffle=false",
1396 command, true, true, null, 0);
1400 public void announcement(Device device, String speak, String bodyText, @Nullable String title,
1401 @Nullable Integer ttsVolume, @Nullable Integer standardVolume) {
1402 String plainSpeak = speak.replaceAll("<.+?>", " ").replaceAll("\\s+", " ").trim();
1403 String plainBody = bodyText.replaceAll("<.+?>", " ").replaceAll("\\s+", " ").trim();
1405 if (plainSpeak.isEmpty() && plainBody.isEmpty()) {
1406 // if there is neither a bodytext nor (except tags) a speaktext, we have nothing to announce
1410 // we lock announcements until we have finished adding this one
1411 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.ANNOUNCEMENT, k -> new ReentrantLock()));
1414 AnnouncementWrapper announcement = Objects.requireNonNull(announcements.computeIfAbsent(
1415 Objects.hash(speak, plainBody, title), k -> new AnnouncementWrapper(speak, plainBody, title)));
1416 announcement.devices.add(device);
1417 announcement.ttsVolumes.add(ttsVolume);
1418 announcement.standardVolumes.add(standardVolume);
1420 // schedule an announcement only if it has not been scheduled before
1421 timers.computeIfAbsent(TimerType.ANNOUNCEMENT,
1422 k -> scheduler.schedule(this::sendAnnouncement, 500, TimeUnit.MILLISECONDS));
1428 private void sendAnnouncement() {
1429 // we lock new announcements until we have dispatched everything
1430 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.ANNOUNCEMENT, k -> new ReentrantLock()));
1433 Iterator<AnnouncementWrapper> iterator = announcements.values().iterator();
1434 while (iterator.hasNext()) {
1435 AnnouncementWrapper announcement = iterator.next();
1437 List<Device> devices = announcement.devices;
1438 if (!devices.isEmpty()) {
1439 JsonAnnouncementContent content = new JsonAnnouncementContent(announcement);
1441 Map<String, Object> parameters = new HashMap<>();
1442 parameters.put("expireAfter", "PT5S");
1443 parameters.put("content", new JsonAnnouncementContent[] { content });
1444 parameters.put("target", new JsonAnnouncementTarget(devices));
1446 String customerId = getCustomerId(devices.get(0).deviceOwnerCustomerId);
1447 if (customerId != null) {
1448 parameters.put("customerId", customerId);
1450 executeSequenceCommandWithVolume(devices, "AlexaAnnouncement", parameters,
1451 announcement.ttsVolumes, announcement.standardVolumes);
1453 } catch (Exception e) {
1454 logger.warn("send announcement fails with unexpected error", e);
1459 // the timer is done anyway immediately after we unlock
1460 timers.remove(TimerType.ANNOUNCEMENT);
1465 public void textToSpeech(Device device, String text, @Nullable Integer ttsVolume,
1466 @Nullable Integer standardVolume) {
1467 if (text.replaceAll("<.+?>", "").replaceAll("\\s+", " ").trim().isEmpty()) {
1471 // we lock TTS until we have finished adding this one
1472 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.TTS, k -> new ReentrantLock()));
1475 TextToSpeech textToSpeech = Objects
1476 .requireNonNull(textToSpeeches.computeIfAbsent(Objects.hash(text), k -> new TextToSpeech(text)));
1477 textToSpeech.devices.add(device);
1478 textToSpeech.ttsVolumes.add(ttsVolume);
1479 textToSpeech.standardVolumes.add(standardVolume);
1480 // schedule a TTS only if it has not been scheduled before
1481 timers.computeIfAbsent(TimerType.TTS,
1482 k -> scheduler.schedule(this::sendTextToSpeech, 500, TimeUnit.MILLISECONDS));
1488 private void sendTextToSpeech() {
1489 // we lock new TTS until we have dispatched everything
1490 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.TTS, k -> new ReentrantLock()));
1493 Iterator<TextToSpeech> iterator = textToSpeeches.values().iterator();
1494 while (iterator.hasNext()) {
1495 TextToSpeech textToSpeech = iterator.next();
1497 List<Device> devices = textToSpeech.devices;
1498 if (!devices.isEmpty()) {
1499 String text = textToSpeech.text;
1500 Map<String, Object> parameters = Map.of("textToSpeak", text);
1501 executeSequenceCommandWithVolume(devices, "Alexa.Speak", parameters, textToSpeech.ttsVolumes,
1502 textToSpeech.standardVolumes);
1504 } catch (Exception e) {
1505 logger.warn("send textToSpeech fails with unexpected error", e);
1510 // the timer is done anyway immediately after we unlock
1511 timers.remove(TimerType.TTS);
1516 public void textCommand(Device device, String text, @Nullable Integer ttsVolume, @Nullable Integer standardVolume) {
1517 if (text.replaceAll("<.+?>", "").replaceAll("\\s+", " ").trim().isEmpty()) {
1521 // we lock TextCommands until we have finished adding this one
1522 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.TEXT_COMMAND, k -> new ReentrantLock()));
1525 TextCommand textCommand = Objects
1526 .requireNonNull(textCommands.computeIfAbsent(Objects.hash(text), k -> new TextCommand(text)));
1527 textCommand.devices.add(device);
1528 textCommand.ttsVolumes.add(ttsVolume);
1529 textCommand.standardVolumes.add(standardVolume);
1530 // schedule a TextCommand only if it has not been scheduled before
1531 timers.computeIfAbsent(TimerType.TEXT_COMMAND,
1532 k -> scheduler.schedule(this::sendTextCommand, 500, TimeUnit.MILLISECONDS));
1538 private synchronized void sendTextCommand() {
1539 // we lock new TTS until we have dispatched everything
1540 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.TEXT_COMMAND, k -> new ReentrantLock()));
1544 Iterator<TextCommand> iterator = textCommands.values().iterator();
1545 while (iterator.hasNext()) {
1546 TextCommand textCommand = iterator.next();
1548 List<Device> devices = textCommand.devices;
1549 if (!devices.isEmpty()) {
1550 String text = textCommand.text;
1551 Map<String, Object> parameters = Map.of("text", text);
1552 executeSequenceCommandWithVolume(devices, "Alexa.TextCommand", parameters,
1553 textCommand.ttsVolumes, textCommand.standardVolumes);
1555 } catch (Exception e) {
1556 logger.warn("send textCommand fails with unexpected error", e);
1561 // the timer is done anyway immediately after we unlock
1562 timers.remove(TimerType.TEXT_COMMAND);
1567 public void volume(Device device, int vol) {
1568 // we lock volume until we have finished adding this one
1569 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.VOLUME, k -> new ReentrantLock()));
1572 Volume volume = Objects.requireNonNull(volumes.computeIfAbsent(vol, k -> new Volume(vol)));
1573 volume.devices.add(device);
1574 volume.volumes.add(vol);
1575 // schedule a TTS only if it has not been scheduled before
1576 timers.computeIfAbsent(TimerType.VOLUME,
1577 k -> scheduler.schedule(this::sendVolume, 500, TimeUnit.MILLISECONDS));
1583 private void sendVolume() {
1584 // we lock new volume until we have dispatched everything
1585 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.VOLUME, k -> new ReentrantLock()));
1588 Iterator<Volume> iterator = volumes.values().iterator();
1589 while (iterator.hasNext()) {
1590 Volume volume = iterator.next();
1592 List<Device> devices = volume.devices;
1593 if (!devices.isEmpty()) {
1594 executeSequenceCommandWithVolume(devices, null, Map.of(), volume.volumes, List.of());
1596 } catch (Exception e) {
1597 logger.warn("send volume fails with unexpected error", e);
1602 // the timer is done anyway immediately after we unlock
1603 timers.remove(TimerType.VOLUME);
1608 private void executeSequenceCommandWithVolume(List<Device> devices, @Nullable String command,
1609 Map<String, Object> parameters, List<@Nullable Integer> ttsVolumes,
1610 List<@Nullable Integer> standardVolumes) {
1611 JsonArray serialNodesToExecute = new JsonArray();
1612 JsonArray ttsVolumeNodesToExecute = new JsonArray();
1613 for (int i = 0; i < devices.size(); i++) {
1614 Integer ttsVolume = ttsVolumes.size() > i ? ttsVolumes.get(i) : null;
1615 Integer standardVolume = standardVolumes.size() > i ? standardVolumes.get(i) : null;
1616 if (ttsVolume != null && (standardVolume != null || !ttsVolume.equals(standardVolume))) {
1617 ttsVolumeNodesToExecute.add(
1618 createExecutionNode(devices.get(i), "Alexa.DeviceControls.Volume", Map.of("value", ttsVolume)));
1621 if (ttsVolumeNodesToExecute.size() > 0) {
1622 JsonObject parallelNodesToExecute = new JsonObject();
1623 parallelNodesToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1624 parallelNodesToExecute.add("nodesToExecute", ttsVolumeNodesToExecute);
1625 serialNodesToExecute.add(parallelNodesToExecute);
1628 if (command != null && !parameters.isEmpty()) {
1629 JsonArray commandNodesToExecute = new JsonArray();
1630 if ("Alexa.Speak".equals(command) || "Alexa.TextCommand".equals(command)) {
1631 for (Device device : devices) {
1632 commandNodesToExecute.add(createExecutionNode(device, command, parameters));
1635 commandNodesToExecute.add(createExecutionNode(devices.get(0), command, parameters));
1637 if (commandNodesToExecute.size() > 0) {
1638 JsonObject parallelNodesToExecute = new JsonObject();
1639 parallelNodesToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1640 parallelNodesToExecute.add("nodesToExecute", commandNodesToExecute);
1641 serialNodesToExecute.add(parallelNodesToExecute);
1645 JsonArray standardVolumeNodesToExecute = new JsonArray();
1646 for (int i = 0; i < devices.size(); i++) {
1647 Integer ttsVolume = ttsVolumes.size() > i ? ttsVolumes.get(i) : null;
1648 Integer standardVolume = standardVolumes.size() > i ? standardVolumes.get(i) : null;
1649 if (ttsVolume != null && standardVolume != null && !ttsVolume.equals(standardVolume)) {
1650 standardVolumeNodesToExecute.add(createExecutionNode(devices.get(i), "Alexa.DeviceControls.Volume",
1651 Map.of("value", standardVolume)));
1654 if (standardVolumeNodesToExecute.size() > 0) {
1655 JsonObject parallelNodesToExecute = new JsonObject();
1656 parallelNodesToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1657 parallelNodesToExecute.add("nodesToExecute", standardVolumeNodesToExecute);
1658 serialNodesToExecute.add(parallelNodesToExecute);
1661 if (serialNodesToExecute.size() > 0) {
1662 executeSequenceNodes(devices, serialNodesToExecute, false);
1666 // commands: Alexa.Weather.Play, Alexa.Traffic.Play, Alexa.FlashBriefing.Play,
1667 // Alexa.GoodMorning.Play,
1668 // Alexa.SingASong.Play, Alexa.TellStory.Play, Alexa.Speak (textToSpeach)
1669 public void executeSequenceCommand(Device device, String command, Map<String, Object> parameters) {
1670 JsonObject nodeToExecute = createExecutionNode(device, command, parameters);
1671 executeSequenceNode(List.of(device), nodeToExecute);
1674 private void executeSequenceNode(List<Device> devices, JsonObject nodeToExecute) {
1675 QueueObject queueObject = new QueueObject();
1676 queueObject.devices = devices;
1677 queueObject.nodeToExecute = nodeToExecute;
1678 String serialNumbers = "";
1679 for (Device device : devices) {
1680 String serialNumber = device.serialNumber;
1681 if (serialNumber != null) {
1682 Objects.requireNonNull(this.devices.computeIfAbsent(serialNumber, k -> new LinkedBlockingQueue<>()))
1683 .offer(queueObject);
1684 serialNumbers = serialNumbers + device.serialNumber + " ";
1687 logger.debug("added {} device {}", queueObject.hashCode(), serialNumbers);
1690 @SuppressWarnings("null") // peek can return null
1691 private void handleExecuteSequenceNode() {
1692 Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.DEVICES, k -> new ReentrantLock()));
1693 if (lock.tryLock()) {
1695 for (String serialNumber : devices.keySet()) {
1696 LinkedBlockingQueue<QueueObject> queueObjects = devices.get(serialNumber);
1697 if (queueObjects != null) {
1698 QueueObject queueObject = queueObjects.peek();
1699 if (queueObject != null) {
1700 Future<?> future = queueObject.future;
1701 if (future == null || future.isDone()) {
1702 boolean execute = true;
1704 for (Device tmpDevice : queueObject.devices) {
1705 if (!serialNumber.equals(tmpDevice.serialNumber)) {
1706 LinkedBlockingQueue<QueueObject> tmpQueueObjects = devices
1707 .get(tmpDevice.serialNumber);
1708 if (tmpQueueObjects != null) {
1709 QueueObject tmpQueueObject = tmpQueueObjects.peek();
1710 Future<?> tmpFuture = tmpQueueObject.future;
1711 if (!queueObject.equals(tmpQueueObject)
1712 || (tmpFuture != null && !tmpFuture.isDone())) {
1716 serial = serial + tmpDevice.serialNumber + " ";
1721 queueObject.future = scheduler.submit(() -> queuedExecuteSequenceNode(queueObject));
1722 logger.debug("thread {} device {}", queueObject.hashCode(), serial);
1734 private void queuedExecuteSequenceNode(QueueObject queueObject) {
1735 JsonObject nodeToExecute = queueObject.nodeToExecute;
1736 ExecutionNodeObject executionNodeObject = getExecutionNodeObject(nodeToExecute);
1737 if (executionNodeObject == null) {
1738 logger.debug("executionNodeObject empty, removing without execution");
1739 removeObjectFromQueueAfterExecutionCompletion(queueObject);
1742 List<String> types = executionNodeObject.types;
1744 if (types.contains("Alexa.DeviceControls.Volume")) {
1747 if (types.contains("Announcement")) {
1753 JsonObject sequenceJson = new JsonObject();
1754 sequenceJson.addProperty("@type", "com.amazon.alexa.behaviors.model.Sequence");
1755 sequenceJson.add("startNode", nodeToExecute);
1757 JsonStartRoutineRequest request = new JsonStartRoutineRequest();
1758 request.sequenceJson = gson.toJson(sequenceJson);
1759 String json = gson.toJson(request);
1761 Map<String, String> headers = new HashMap<>();
1762 headers.put("Routines-Version", "1.1.218665");
1764 String text = executionNodeObject.text;
1766 text = text.replaceAll("<.+?>", " ").replaceAll("\\s+", " ").trim();
1767 delay += text.length() * 150;
1770 makeRequest("POST", alexaServer + "/api/behaviors/preview", json, true, true, null, 3);
1772 Thread.sleep(delay);
1773 } catch (IOException | URISyntaxException | InterruptedException e) {
1774 logger.warn("execute sequence node fails with unexpected error", e);
1776 removeObjectFromQueueAfterExecutionCompletion(queueObject);
1780 private void removeObjectFromQueueAfterExecutionCompletion(QueueObject queueObject) {
1782 for (Device device : queueObject.devices) {
1783 String serialNumber = device.serialNumber;
1784 if (serialNumber != null) {
1785 LinkedBlockingQueue<?> queue = devices.get(serialNumber);
1786 if (queue != null) {
1787 queue.remove(queueObject);
1789 serial = serial + serialNumber + " ";
1792 logger.debug("removed {} device {}", queueObject.hashCode(), serial);
1795 private void executeSequenceNodes(List<Device> devices, JsonArray nodesToExecute, boolean parallel) {
1796 JsonObject serialNode = new JsonObject();
1798 serialNode.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1800 serialNode.addProperty("@type", "com.amazon.alexa.behaviors.model.SerialNode");
1803 serialNode.add("nodesToExecute", nodesToExecute);
1805 executeSequenceNode(devices, serialNode);
1808 private JsonObject createExecutionNode(@Nullable Device device, String command, Map<String, Object> parameters) {
1809 JsonObject operationPayload = new JsonObject();
1810 if (device != null) {
1811 operationPayload.addProperty("deviceType", device.deviceType);
1812 operationPayload.addProperty("deviceSerialNumber", device.serialNumber);
1813 operationPayload.addProperty("locale", "");
1814 operationPayload.addProperty("customerId", getCustomerId(device.deviceOwnerCustomerId));
1816 for (String key : parameters.keySet()) {
1817 Object value = parameters.get(key);
1818 if (value instanceof String) {
1819 operationPayload.addProperty(key, (String) value);
1820 } else if (value instanceof Number) {
1821 operationPayload.addProperty(key, (Number) value);
1822 } else if (value instanceof Boolean) {
1823 operationPayload.addProperty(key, (Boolean) value);
1824 } else if (value instanceof Character) {
1825 operationPayload.addProperty(key, (Character) value);
1827 operationPayload.add(key, gson.toJsonTree(value));
1831 JsonObject nodeToExecute = new JsonObject();
1832 nodeToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode");
1833 nodeToExecute.addProperty("type", command);
1834 if ("Alexa.TextCommand".equals(command)) {
1835 nodeToExecute.addProperty("skillId", "amzn1.ask.1p.tellalexa");
1837 nodeToExecute.add("operationPayload", operationPayload);
1838 return nodeToExecute;
1842 private ExecutionNodeObject getExecutionNodeObject(JsonObject nodeToExecute) {
1843 ExecutionNodeObject executionNodeObject = new ExecutionNodeObject();
1844 if (nodeToExecute.has("nodesToExecute")) {
1845 JsonArray serialNodesToExecute = nodeToExecute.getAsJsonArray("nodesToExecute");
1846 if (serialNodesToExecute != null && serialNodesToExecute.size() > 0) {
1847 for (int i = 0; i < serialNodesToExecute.size(); i++) {
1848 JsonObject serialNodesToExecuteJsonObject = serialNodesToExecute.get(i).getAsJsonObject();
1849 if (serialNodesToExecuteJsonObject.has("nodesToExecute")) {
1850 JsonArray parallelNodesToExecute = serialNodesToExecuteJsonObject
1851 .getAsJsonArray("nodesToExecute");
1852 if (parallelNodesToExecute != null && parallelNodesToExecute.size() > 0) {
1853 JsonObject parallelNodesToExecuteJsonObject = parallelNodesToExecute.get(0)
1855 if (processNodesToExecuteJsonObject(executionNodeObject,
1856 parallelNodesToExecuteJsonObject)) {
1861 if (processNodesToExecuteJsonObject(executionNodeObject, serialNodesToExecuteJsonObject)) {
1869 return executionNodeObject;
1872 private boolean processNodesToExecuteJsonObject(ExecutionNodeObject executionNodeObject,
1873 JsonObject nodesToExecuteJsonObject) {
1874 if (nodesToExecuteJsonObject.has("type")) {
1875 executionNodeObject.types.add(nodesToExecuteJsonObject.get("type").getAsString());
1876 if (nodesToExecuteJsonObject.has("operationPayload")) {
1877 JsonObject operationPayload = nodesToExecuteJsonObject.getAsJsonObject("operationPayload");
1878 if (operationPayload != null) {
1879 if (operationPayload.has("textToSpeak")) {
1880 executionNodeObject.text = operationPayload.get("textToSpeak").getAsString();
1882 } else if (operationPayload.has("text")) {
1883 executionNodeObject.text = operationPayload.get("text").getAsString();
1885 } else if (operationPayload.has("content")) {
1886 JsonArray content = operationPayload.getAsJsonArray("content");
1887 if (content != null && content.size() > 0) {
1888 JsonObject contentJsonObject = content.get(0).getAsJsonObject();
1889 if (contentJsonObject.has("speak")) {
1890 JsonObject speak = contentJsonObject.getAsJsonObject("speak");
1891 if (speak != null && speak.has("value")) {
1892 executionNodeObject.text = speak.get("value").getAsString();
1904 public void startRoutine(Device device, String utterance)
1905 throws IOException, URISyntaxException, InterruptedException {
1906 JsonAutomation found = null;
1907 String deviceLocale = "";
1908 JsonAutomation[] routines = getRoutines();
1909 if (routines == null) {
1912 for (JsonAutomation routine : routines) {
1913 if (routine != null) {
1914 if (routine.sequence != null) {
1915 List<JsonAutomation.Trigger> triggers = Objects.requireNonNullElse(routine.triggers, List.of());
1916 for (JsonAutomation.Trigger trigger : triggers) {
1917 Payload payload = trigger.payload;
1918 if (payload == null) {
1921 String payloadUtterance = payload.utterance;
1922 if (payloadUtterance != null && payloadUtterance.equalsIgnoreCase(utterance)) {
1924 deviceLocale = payload.locale;
1931 if (found != null) {
1932 String sequenceJson = gson.toJson(found.sequence);
1934 JsonStartRoutineRequest request = new JsonStartRoutineRequest();
1935 request.behaviorId = found.automationId;
1938 // "deviceType":"ALEXA_CURRENT_DEVICE_TYPE"
1939 String deviceType = "\"deviceType\":\"ALEXA_CURRENT_DEVICE_TYPE\"";
1940 String newDeviceType = "\"deviceType\":\"" + device.deviceType + "\"";
1941 sequenceJson = sequenceJson.replace(deviceType.subSequence(0, deviceType.length()),
1942 newDeviceType.subSequence(0, newDeviceType.length()));
1944 // "deviceSerialNumber":"ALEXA_CURRENT_DSN"
1945 String deviceSerial = "\"deviceSerialNumber\":\"ALEXA_CURRENT_DSN\"";
1946 String newDeviceSerial = "\"deviceSerialNumber\":\"" + device.serialNumber + "\"";
1947 sequenceJson = sequenceJson.replace(deviceSerial.subSequence(0, deviceSerial.length()),
1948 newDeviceSerial.subSequence(0, newDeviceSerial.length()));
1950 // "customerId": "ALEXA_CUSTOMER_ID"
1951 String customerId = "\"customerId\":\"ALEXA_CUSTOMER_ID\"";
1952 String newCustomerId = "\"customerId\":\"" + getCustomerId(device.deviceOwnerCustomerId) + "\"";
1953 sequenceJson = sequenceJson.replace(customerId.subSequence(0, customerId.length()),
1954 newCustomerId.subSequence(0, newCustomerId.length()));
1956 // "locale": "ALEXA_CURRENT_LOCALE"
1957 String locale = "\"locale\":\"ALEXA_CURRENT_LOCALE\"";
1958 String newlocale = deviceLocale != null && !deviceLocale.isEmpty() ? "\"locale\":\"" + deviceLocale + "\""
1959 : "\"locale\":null";
1960 sequenceJson = sequenceJson.replace(locale.subSequence(0, locale.length()),
1961 newlocale.subSequence(0, newlocale.length()));
1963 request.sequenceJson = sequenceJson;
1965 String requestJson = gson.toJson(request);
1966 makeRequest("POST", alexaServer + "/api/behaviors/preview", requestJson, true, true, null, 3);
1968 logger.warn("Routine {} not found", utterance);
1972 public @Nullable JsonAutomation @Nullable [] getRoutines()
1973 throws IOException, URISyntaxException, InterruptedException {
1974 String json = makeRequestAndReturnString(alexaServer + "/api/behaviors/v2/automations?limit=2000");
1975 JsonAutomation[] result = parseJson(json, JsonAutomation[].class);
1979 public List<JsonFeed> getEnabledFlashBriefings() throws IOException, URISyntaxException, InterruptedException {
1980 String json = makeRequestAndReturnString(alexaServer + "/api/content-skills/enabled-feeds");
1981 JsonEnabledFeeds result = parseJson(json, JsonEnabledFeeds.class);
1982 return Objects.requireNonNullElse(result.enabledFeeds, List.of());
1985 public void setEnabledFlashBriefings(List<JsonFeed> enabledFlashBriefing)
1986 throws IOException, URISyntaxException, InterruptedException {
1987 JsonEnabledFeeds enabled = new JsonEnabledFeeds();
1988 enabled.enabledFeeds = enabledFlashBriefing;
1989 String json = gsonWithNullSerialization.toJson(enabled);
1990 makeRequest("POST", alexaServer + "/api/content-skills/enabled-feeds", json, true, true, null, 0);
1993 public List<JsonNotificationSound> getNotificationSounds(Device device)
1994 throws IOException, URISyntaxException, InterruptedException {
1995 String json = makeRequestAndReturnString(
1996 alexaServer + "/api/notification/sounds?deviceSerialNumber=" + device.serialNumber + "&deviceType="
1997 + device.deviceType + "&softwareVersion=" + device.softwareVersion);
1998 JsonNotificationSounds result = parseJson(json, JsonNotificationSounds.class);
1999 return Objects.requireNonNullElse(result.notificationSounds, List.of());
2002 public List<JsonNotificationResponse> notifications() throws IOException, URISyntaxException, InterruptedException {
2003 String response = makeRequestAndReturnString(alexaServer + "/api/notifications");
2004 JsonNotificationsResponse result = parseJson(response, JsonNotificationsResponse.class);
2005 return Objects.requireNonNullElse(result.notifications, List.of());
2008 public @Nullable JsonNotificationResponse notification(Device device, String type, @Nullable String label,
2009 @Nullable JsonNotificationSound sound) throws IOException, URISyntaxException, InterruptedException {
2010 Date date = new Date(new Date().getTime());
2011 long createdDate = date.getTime();
2012 Date alarm = new Date(createdDate + 5000); // add 5 seconds, because amazon does not except calls for times in
2013 // the past (compared with the server time)
2014 long alarmTime = alarm.getTime();
2016 JsonNotificationRequest request = new JsonNotificationRequest();
2017 request.type = type;
2018 request.deviceSerialNumber = device.serialNumber;
2019 request.deviceType = device.deviceType;
2020 request.createdDate = createdDate;
2021 request.alarmTime = alarmTime;
2022 request.reminderLabel = label;
2023 request.sound = sound;
2024 request.originalDate = new SimpleDateFormat("yyyy-MM-dd").format(alarm);
2025 request.originalTime = new SimpleDateFormat("HH:mm:ss.SSSS").format(alarm);
2026 request.type = type;
2027 request.id = "create" + type;
2029 String data = gsonWithNullSerialization.toJson(request);
2030 String response = makeRequestAndReturnString("PUT", alexaServer + "/api/notifications/createReminder", data,
2032 JsonNotificationResponse result = parseJson(response, JsonNotificationResponse.class);
2036 public void stopNotification(JsonNotificationResponse notification)
2037 throws IOException, URISyntaxException, InterruptedException {
2038 makeRequestAndReturnString("DELETE", alexaServer + "/api/notifications/" + notification.id, null, true, null);
2041 public @Nullable JsonNotificationResponse getNotificationState(JsonNotificationResponse notification)
2042 throws IOException, URISyntaxException, InterruptedException {
2043 String response = makeRequestAndReturnString("GET", alexaServer + "/api/notifications/" + notification.id, null,
2045 JsonNotificationResponse result = parseJson(response, JsonNotificationResponse.class);
2049 public List<JsonMusicProvider> getMusicProviders() {
2051 Map<String, String> headers = new HashMap<>();
2052 headers.put("Routines-Version", "1.1.218665");
2053 String response = makeRequestAndReturnString("GET",
2054 alexaServer + "/api/behaviors/entities?skillId=amzn1.ask.1p.music", null, true, headers);
2055 if (!response.isEmpty()) {
2056 JsonMusicProvider[] musicProviders = parseJson(response, JsonMusicProvider[].class);
2057 return Arrays.asList(musicProviders);
2059 } catch (IOException | URISyntaxException | InterruptedException e) {
2060 logger.warn("getMusicProviders fails: {}", e.getMessage());
2065 public void playMusicVoiceCommand(Device device, String providerId, String voiceCommand)
2066 throws IOException, URISyntaxException, InterruptedException {
2067 JsonPlaySearchPhraseOperationPayload payload = new JsonPlaySearchPhraseOperationPayload();
2068 payload.customerId = getCustomerId(device.deviceOwnerCustomerId);
2069 payload.locale = "ALEXA_CURRENT_LOCALE";
2070 payload.musicProviderId = providerId;
2071 payload.searchPhrase = voiceCommand;
2073 String playloadString = gson.toJson(payload);
2075 JsonObject postValidationJson = new JsonObject();
2077 postValidationJson.addProperty("type", "Alexa.Music.PlaySearchPhrase");
2078 postValidationJson.addProperty("operationPayload", playloadString);
2080 String postDataValidate = postValidationJson.toString();
2082 String validateResultJson = makeRequestAndReturnString("POST",
2083 alexaServer + "/api/behaviors/operation/validate", postDataValidate, true, null);
2085 if (!validateResultJson.isEmpty()) {
2086 JsonPlayValidationResult validationResult = parseJson(validateResultJson, JsonPlayValidationResult.class);
2087 JsonPlaySearchPhraseOperationPayload validatedOperationPayload = validationResult.operationPayload;
2088 if (validatedOperationPayload != null) {
2089 payload.sanitizedSearchPhrase = validatedOperationPayload.sanitizedSearchPhrase;
2090 payload.searchPhrase = validatedOperationPayload.searchPhrase;
2094 payload.locale = null;
2095 payload.deviceSerialNumber = device.serialNumber;
2096 payload.deviceType = device.deviceType;
2098 JsonObject sequenceJson = new JsonObject();
2099 sequenceJson.addProperty("@type", "com.amazon.alexa.behaviors.model.Sequence");
2100 JsonObject startNodeJson = new JsonObject();
2101 startNodeJson.addProperty("@type", "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode");
2102 startNodeJson.addProperty("type", "Alexa.Music.PlaySearchPhrase");
2103 startNodeJson.add("operationPayload", gson.toJsonTree(payload));
2104 sequenceJson.add("startNode", startNodeJson);
2106 JsonStartRoutineRequest startRoutineRequest = new JsonStartRoutineRequest();
2107 startRoutineRequest.sequenceJson = sequenceJson.toString();
2108 startRoutineRequest.status = null;
2110 String postData = gson.toJson(startRoutineRequest);
2111 makeRequest("POST", alexaServer + "/api/behaviors/preview", postData, true, true, null, 3);
2114 public @Nullable JsonEqualizer getEqualizer(Device device)
2115 throws IOException, URISyntaxException, InterruptedException {
2116 String json = makeRequestAndReturnString(
2117 alexaServer + "/api/equalizer/" + device.serialNumber + "/" + device.deviceType);
2118 return parseJson(json, JsonEqualizer.class);
2121 public void setEqualizer(Device device, JsonEqualizer settings)
2122 throws IOException, URISyntaxException, InterruptedException {
2123 String postData = gson.toJson(settings);
2124 makeRequest("POST", alexaServer + "/api/equalizer/" + device.serialNumber + "/" + device.deviceType, postData,
2125 true, true, null, 0);
2128 public static class AnnouncementWrapper {
2129 public List<Device> devices = new ArrayList<>();
2130 public String speak;
2131 public String bodyText;
2132 public @Nullable String title;
2133 public List<@Nullable Integer> ttsVolumes = new ArrayList<>();
2134 public List<@Nullable Integer> standardVolumes = new ArrayList<>();
2136 public AnnouncementWrapper(String speak, String bodyText, @Nullable String title) {
2138 this.bodyText = bodyText;
2143 private static class TextToSpeech {
2144 public List<Device> devices = new ArrayList<>();
2146 public List<@Nullable Integer> ttsVolumes = new ArrayList<>();
2147 public List<@Nullable Integer> standardVolumes = new ArrayList<>();
2149 public TextToSpeech(String text) {
2154 private static class TextCommand {
2155 public List<Device> devices = new ArrayList<>();
2157 public List<@Nullable Integer> ttsVolumes = new ArrayList<>();
2158 public List<@Nullable Integer> standardVolumes = new ArrayList<>();
2160 public TextCommand(String text) {
2165 private static class Volume {
2166 public List<Device> devices = new ArrayList<>();
2168 public List<@Nullable Integer> volumes = new ArrayList<>();
2170 public Volume(int volume) {
2171 this.volume = volume;
2175 private static class QueueObject {
2176 public @Nullable Future<?> future;
2177 public List<Device> devices = List.of();
2178 public JsonObject nodeToExecute = new JsonObject();
2181 private static class ExecutionNodeObject {
2182 public List<String> types = new ArrayList<>();