]> git.basschouten.com Git - openhab-addons.git/blob
9c5b64d84b045fc0dd10d320c63b960480566116
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.amazonechocontrol.internal;
14
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;
22 import java.net.URI;
23 import java.net.URISyntaxException;
24 import java.net.URL;
25 import java.net.URLDecoder;
26 import java.net.URLEncoder;
27 import java.nio.charset.StandardCharsets;
28 import java.text.SimpleDateFormat;
29 import java.util.ArrayList;
30 import java.util.Arrays;
31 import java.util.Base64;
32 import java.util.Collections;
33 import java.util.Date;
34 import java.util.HashMap;
35 import java.util.Iterator;
36 import java.util.LinkedHashMap;
37 import java.util.List;
38 import java.util.Map;
39 import java.util.Objects;
40 import java.util.Random;
41 import java.util.Scanner;
42 import java.util.Set;
43 import java.util.concurrent.ConcurrentHashMap;
44 import java.util.concurrent.Future;
45 import java.util.concurrent.LinkedBlockingQueue;
46 import java.util.concurrent.ScheduledExecutorService;
47 import java.util.concurrent.ScheduledFuture;
48 import java.util.concurrent.TimeUnit;
49 import java.util.concurrent.locks.Lock;
50 import java.util.concurrent.locks.ReentrantLock;
51 import java.util.regex.Matcher;
52 import java.util.regex.Pattern;
53 import java.util.stream.Collectors;
54 import java.util.stream.StreamSupport;
55 import java.util.zip.GZIPInputStream;
56
57 import javax.net.ssl.HttpsURLConnection;
58
59 import org.eclipse.jdt.annotation.NonNullByDefault;
60 import org.eclipse.jdt.annotation.Nullable;
61 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonActivities;
62 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonActivities.Activity;
63 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAnnouncementContent;
64 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAnnouncementTarget;
65 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAscendingAlarm;
66 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAscendingAlarm.AscendingAlarmModel;
67 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAutomation;
68 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAutomation.Payload;
69 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAutomation.Trigger;
70 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBluetoothStates;
71 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBootstrapResult;
72 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBootstrapResult.Authentication;
73 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDeviceNotificationState;
74 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDeviceNotificationState.DeviceNotificationState;
75 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDevices;
76 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDevices.Device;
77 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonEnabledFeeds;
78 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonEqualizer;
79 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonExchangeTokenResponse;
80 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonExchangeTokenResponse.Cookie;
81 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonFeed;
82 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonMediaState;
83 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonMusicProvider;
84 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNetworkDetails;
85 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationRequest;
86 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationResponse;
87 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationSound;
88 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationSounds;
89 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationsResponse;
90 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlaySearchPhraseOperationPayload;
91 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlayValidationResult;
92 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlayerState;
93 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlaylists;
94 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppRequest;
95 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse;
96 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Bearer;
97 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.DeviceInfo;
98 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Extensions;
99 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Response;
100 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Success;
101 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRegisterAppResponse.Tokens;
102 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonRenewTokenResponse;
103 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonSmartHomeDevices.SmartHomeDevice;
104 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonSmartHomeGroups.SmartHomeGroup;
105 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonStartRoutineRequest;
106 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonUsersMeResponse;
107 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonWakeWords;
108 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonWakeWords.WakeWord;
109 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonWebSiteCookie;
110 import org.openhab.binding.amazonechocontrol.internal.jsons.SmartHomeBaseDevice;
111 import org.openhab.core.common.ThreadPoolManager;
112 import org.openhab.core.library.types.QuantityType;
113 import org.openhab.core.library.unit.SIUnits;
114 import org.openhab.core.util.HexUtils;
115 import org.slf4j.Logger;
116 import org.slf4j.LoggerFactory;
117
118 import com.google.gson.Gson;
119 import com.google.gson.GsonBuilder;
120 import com.google.gson.JsonArray;
121 import com.google.gson.JsonElement;
122 import com.google.gson.JsonObject;
123 import com.google.gson.JsonParseException;
124 import com.google.gson.JsonSyntaxException;
125
126 /**
127  * The {@link Connection} is responsible for the connection to the amazon server
128  * and handling of the commands
129  *
130  * @author Michael Geramb - Initial contribution
131  */
132 @NonNullByDefault
133 public class Connection {
134     private static final String THING_THREADPOOL_NAME = "thingHandler";
135     private static final long EXPIRES_IN = 432000; // five days
136     private static final Pattern CHARSET_PATTERN = Pattern.compile("(?i)\\bcharset=\\s*\"?([^\\s;\"]*)");
137     private static final String DEVICE_TYPE = "A2IVLV5VM2W81";
138
139     private final Logger logger = LoggerFactory.getLogger(Connection.class);
140
141     protected final ScheduledExecutorService scheduler = ThreadPoolManager.getScheduledPool(THING_THREADPOOL_NAME);
142
143     private final Random rand = new Random();
144     private final CookieManager cookieManager = new CookieManager();
145     private final Gson gson;
146     private final Gson gsonWithNullSerialization;
147
148     private String amazonSite = "amazon.com";
149     private String alexaServer = "https://alexa.amazon.com";
150     private final String userAgent;
151     private String frc;
152     private String serial;
153     private String deviceId;
154
155     private @Nullable String refreshToken;
156     private @Nullable Date loginTime;
157     private @Nullable Date verifyTime;
158     private long renewTime = 0;
159     private @Nullable String deviceName;
160     private @Nullable String accountCustomerId;
161     private @Nullable String customerName;
162
163     private Map<Integer, AnnouncementWrapper> announcements = Collections.synchronizedMap(new LinkedHashMap<>());
164     private Map<Integer, TextToSpeech> textToSpeeches = Collections.synchronizedMap(new LinkedHashMap<>());
165     private Map<Integer, TextCommand> textCommands = Collections.synchronizedMap(new LinkedHashMap<>());
166
167     private Map<Integer, Volume> volumes = Collections.synchronizedMap(new LinkedHashMap<>());
168     private Map<String, LinkedBlockingQueue<QueueObject>> devices = Collections.synchronizedMap(new LinkedHashMap<>());
169
170     private final Map<TimerType, ScheduledFuture<?>> timers = new ConcurrentHashMap<>();
171     private final Map<TimerType, Lock> locks = new ConcurrentHashMap<>();
172
173     private enum TimerType {
174         ANNOUNCEMENT,
175         TTS,
176         VOLUME,
177         DEVICES,
178         TEXT_COMMAND
179     }
180
181     public Connection(@Nullable Connection oldConnection, Gson gson) {
182         this.gson = gson;
183         String frc = null;
184         String serial = null;
185         String deviceId = null;
186         if (oldConnection != null) {
187             deviceId = oldConnection.getDeviceId();
188             frc = oldConnection.getFrc();
189             serial = oldConnection.getSerial();
190         }
191         if (frc != null) {
192             this.frc = frc;
193         } else {
194             // generate frc
195             byte[] frcBinary = new byte[313];
196             rand.nextBytes(frcBinary);
197             this.frc = Base64.getEncoder().encodeToString(frcBinary);
198         }
199         if (serial != null) {
200             this.serial = serial;
201         } else {
202             // generate serial
203             byte[] serialBinary = new byte[16];
204             rand.nextBytes(serialBinary);
205             this.serial = HexUtils.bytesToHex(serialBinary);
206         }
207         if (deviceId != null) {
208             this.deviceId = deviceId;
209         } else {
210             this.deviceId = generateDeviceId();
211         }
212
213         // build user agent
214         this.userAgent = "AmazonWebView/Amazon Alexa/2.2.223830.0/iOS/11.4.1/iPhone";
215         GsonBuilder gsonBuilder = new GsonBuilder();
216         gsonWithNullSerialization = gsonBuilder.create();
217
218         replaceTimer(TimerType.DEVICES,
219                 scheduler.scheduleWithFixedDelay(this::handleExecuteSequenceNode, 0, 500, TimeUnit.MILLISECONDS));
220     }
221
222     /**
223      * Generate a new device id
224      * <p>
225      * The device id consists of 16 random bytes in upper-case hex format, a # as separator and a fixed DEVICE_TYPE
226      *
227      * @return a string containing the new device-id
228      */
229     private String generateDeviceId() {
230         byte[] bytes = new byte[16];
231         rand.nextBytes(bytes);
232         String hexStr = HexUtils.bytesToHex(bytes).toUpperCase() + "#" + DEVICE_TYPE;
233         return HexUtils.bytesToHex(hexStr.getBytes());
234     }
235
236     /**
237      * Check if deviceId is valid (consisting of hex(hex(16 random bytes)) + "#" + DEVICE_TYPE)
238      *
239      * @param deviceId the deviceId
240      * @return true if valid, false if invalid
241      */
242     private boolean checkDeviceIdIsValid(@Nullable String deviceId) {
243         if (deviceId != null && deviceId.matches("^[0-9a-fA-F]{92}$")) {
244             String hexString = new String(HexUtils.hexToBytes(deviceId));
245             if (hexString.matches("^[0-9A-F]{32}#" + DEVICE_TYPE + "$")) {
246                 return true;
247             }
248         }
249         return false;
250     }
251
252     private void setAmazonSite(@Nullable String amazonSite) {
253         String correctedAmazonSite = amazonSite != null ? amazonSite : "amazon.com";
254         if (correctedAmazonSite.toLowerCase().startsWith("http://")) {
255             correctedAmazonSite = correctedAmazonSite.substring(7);
256         }
257         if (correctedAmazonSite.toLowerCase().startsWith("https://")) {
258             correctedAmazonSite = correctedAmazonSite.substring(8);
259         }
260         if (correctedAmazonSite.toLowerCase().startsWith("www.")) {
261             correctedAmazonSite = correctedAmazonSite.substring(4);
262         }
263         if (correctedAmazonSite.toLowerCase().startsWith("alexa.")) {
264             correctedAmazonSite = correctedAmazonSite.substring(6);
265         }
266         this.amazonSite = correctedAmazonSite;
267         alexaServer = "https://alexa." + this.amazonSite;
268     }
269
270     public @Nullable Date tryGetLoginTime() {
271         return loginTime;
272     }
273
274     public @Nullable Date tryGetVerifyTime() {
275         return verifyTime;
276     }
277
278     public String getFrc() {
279         return frc;
280     }
281
282     public String getSerial() {
283         return serial;
284     }
285
286     public String getDeviceId() {
287         return deviceId;
288     }
289
290     public String getAmazonSite() {
291         return amazonSite;
292     }
293
294     public String getAlexaServer() {
295         return alexaServer;
296     }
297
298     public String getDeviceName() {
299         String deviceName = this.deviceName;
300         if (deviceName == null) {
301             return "Unknown";
302         }
303         return deviceName;
304     }
305
306     public String getCustomerId() {
307         String customerId = this.accountCustomerId;
308         if (customerId == null) {
309             return "Unknown";
310         }
311         return customerId;
312     }
313
314     public String getCustomerName() {
315         String customerName = this.customerName;
316         if (customerName == null) {
317             return "Unknown";
318         }
319         return customerName;
320     }
321
322     public boolean isSequenceNodeQueueRunning() {
323         return devices.values().stream().anyMatch(
324                 (queueObjects) -> (queueObjects.stream().anyMatch(queueObject -> queueObject.future != null)));
325     }
326
327     public String serializeLoginData() {
328         Date loginTime = this.loginTime;
329         if (refreshToken == null || loginTime == null) {
330             return "";
331         }
332         StringBuilder builder = new StringBuilder();
333         builder.append("7\n"); // version
334         builder.append(frc);
335         builder.append("\n");
336         builder.append(serial);
337         builder.append("\n");
338         builder.append(deviceId);
339         builder.append("\n");
340         builder.append(refreshToken);
341         builder.append("\n");
342         builder.append(amazonSite);
343         builder.append("\n");
344         builder.append(deviceName);
345         builder.append("\n");
346         builder.append(accountCustomerId);
347         builder.append("\n");
348         builder.append(loginTime.getTime());
349         builder.append("\n");
350         List<HttpCookie> cookies = cookieManager.getCookieStore().getCookies();
351         builder.append(cookies.size());
352         builder.append("\n");
353         for (HttpCookie cookie : cookies) {
354             writeValue(builder, cookie.getName());
355             writeValue(builder, cookie.getValue());
356             writeValue(builder, cookie.getComment());
357             writeValue(builder, cookie.getCommentURL());
358             writeValue(builder, cookie.getDomain());
359             writeValue(builder, cookie.getMaxAge());
360             writeValue(builder, cookie.getPath());
361             writeValue(builder, cookie.getPortlist());
362             writeValue(builder, cookie.getVersion());
363             writeValue(builder, cookie.getSecure());
364             writeValue(builder, cookie.getDiscard());
365         }
366         return builder.toString();
367     }
368
369     private void writeValue(StringBuilder builder, @Nullable Object value) {
370         if (value == null) {
371             builder.append('0');
372         } else {
373             builder.append('1');
374             builder.append("\n");
375             builder.append(value.toString());
376         }
377         builder.append("\n");
378     }
379
380     private String readValue(Scanner scanner) {
381         if (scanner.nextLine().equals("1")) {
382             String result = scanner.nextLine();
383             if (result != null) {
384                 return result;
385             }
386         }
387         return "";
388     }
389
390     public boolean tryRestoreLogin(@Nullable String data, @Nullable String overloadedDomain) {
391         Date loginTime = tryRestoreSessionData(data, overloadedDomain);
392         if (loginTime != null) {
393             try {
394                 if (verifyLogin()) {
395                     this.loginTime = loginTime;
396                     return true;
397                 }
398             } catch (IOException e) {
399                 return false;
400             } catch (URISyntaxException | InterruptedException e) {
401             }
402         }
403         return false;
404     }
405
406     private @Nullable Date tryRestoreSessionData(@Nullable String data, @Nullable String overloadedDomain) {
407         // verify store data
408         if (data == null || data.isEmpty()) {
409             return null;
410         }
411         Scanner scanner = new Scanner(data);
412         String version = scanner.nextLine();
413         // check if serialize version is supported
414         if (!"5".equals(version) && !"6".equals(version) && !"7".equals(version)) {
415             scanner.close();
416             return null;
417         }
418         int intVersion = Integer.parseInt(version);
419
420         frc = scanner.nextLine();
421         serial = scanner.nextLine();
422         deviceId = scanner.nextLine();
423
424         // Recreate session and cookies
425         refreshToken = scanner.nextLine();
426         String domain = scanner.nextLine();
427         if (overloadedDomain != null) {
428             domain = overloadedDomain;
429         }
430         setAmazonSite(domain);
431
432         deviceName = scanner.nextLine();
433
434         if (intVersion > 5) {
435             String accountCustomerId = scanner.nextLine();
436             // Note: version 5 have wrong customer id serialized.
437             // Only use it, if it at least version 6 of serialization
438             if (intVersion > 6) {
439                 if (!"null".equals(accountCustomerId)) {
440                     this.accountCustomerId = accountCustomerId;
441                 }
442             }
443         }
444
445         Date loginTime = new Date(Long.parseLong(scanner.nextLine()));
446         CookieStore cookieStore = cookieManager.getCookieStore();
447         cookieStore.removeAll();
448
449         Integer numberOfCookies = Integer.parseInt(scanner.nextLine());
450         for (Integer i = 0; i < numberOfCookies; i++) {
451             String name = readValue(scanner);
452             String value = readValue(scanner);
453
454             HttpCookie clientCookie = new HttpCookie(name, value);
455             clientCookie.setComment(readValue(scanner));
456             clientCookie.setCommentURL(readValue(scanner));
457             clientCookie.setDomain(readValue(scanner));
458             clientCookie.setMaxAge(Long.parseLong(readValue(scanner)));
459             clientCookie.setPath(readValue(scanner));
460             clientCookie.setPortlist(readValue(scanner));
461             clientCookie.setVersion(Integer.parseInt(readValue(scanner)));
462             clientCookie.setSecure(Boolean.parseBoolean(readValue(scanner)));
463             clientCookie.setDiscard(Boolean.parseBoolean(readValue(scanner)));
464
465             cookieStore.add(null, clientCookie);
466         }
467         scanner.close();
468         try {
469             checkRenewSession();
470
471             String accountCustomerId = this.accountCustomerId;
472             if (accountCustomerId == null || accountCustomerId.isEmpty()) {
473                 List<Device> devices = this.getDeviceList();
474                 accountCustomerId = devices.stream().filter(device -> serial.equals(device.serialNumber)).findAny()
475                         .map(device -> device.deviceOwnerCustomerId).orElse(null);
476                 if (accountCustomerId == null || accountCustomerId.isEmpty()) {
477                     accountCustomerId = devices.stream().filter(device -> "This Device".equals(device.accountName))
478                             .findAny().map(device -> {
479                                 serial = Objects.requireNonNullElse(device.serialNumber, serial);
480                                 return device.deviceOwnerCustomerId;
481                             }).orElse(null);
482                 }
483                 this.accountCustomerId = accountCustomerId;
484             }
485         } catch (URISyntaxException | IOException | InterruptedException | ConnectionException e) {
486             logger.debug("Getting account customer Id failed", e);
487         }
488         return loginTime;
489     }
490
491     private @Nullable Authentication tryGetBootstrap() throws IOException, URISyntaxException, InterruptedException {
492         HttpsURLConnection connection = makeRequest("GET", alexaServer + "/api/bootstrap", null, false, false, null, 0);
493         String contentType = connection.getContentType();
494         if (connection.getResponseCode() == 200 && contentType != null
495                 && contentType.toLowerCase().startsWith("application/json")) {
496             try {
497                 String bootstrapResultJson = convertStream(connection);
498                 JsonBootstrapResult result = parseJson(bootstrapResultJson, JsonBootstrapResult.class);
499                 if (result != null) {
500                     Authentication authentication = result.authentication;
501                     if (authentication != null && authentication.authenticated) {
502                         this.customerName = authentication.customerName;
503                         if (this.accountCustomerId == null) {
504                             this.accountCustomerId = authentication.customerId;
505                         }
506                         return authentication;
507                     }
508                 }
509             } catch (JsonSyntaxException | IllegalStateException e) {
510                 logger.info("No valid json received", e);
511                 return null;
512             }
513         }
514         return null;
515     }
516
517     public String convertStream(HttpsURLConnection connection) throws IOException {
518         InputStream input = connection.getInputStream();
519         if (input == null) {
520             return "";
521         }
522
523         InputStream readerStream;
524         if ("gzip".equalsIgnoreCase(connection.getContentEncoding())) {
525             readerStream = new GZIPInputStream(connection.getInputStream());
526         } else {
527             readerStream = input;
528         }
529         String contentType = connection.getContentType();
530         String charSet = null;
531         if (contentType != null) {
532             Matcher m = CHARSET_PATTERN.matcher(contentType);
533             if (m.find()) {
534                 charSet = m.group(1).trim().toUpperCase();
535             }
536         }
537
538         Scanner inputScanner = charSet == null || charSet.isEmpty()
539                 ? new Scanner(readerStream, StandardCharsets.UTF_8.name())
540                 : new Scanner(readerStream, charSet);
541         Scanner scannerWithoutDelimiter = inputScanner.useDelimiter("\\A");
542         String result = scannerWithoutDelimiter.hasNext() ? scannerWithoutDelimiter.next() : null;
543         inputScanner.close();
544         scannerWithoutDelimiter.close();
545         input.close();
546         if (result == null) {
547             result = "";
548         }
549         return result;
550     }
551
552     public String makeRequestAndReturnString(String url) throws IOException, URISyntaxException, InterruptedException {
553         return makeRequestAndReturnString("GET", url, null, false, null);
554     }
555
556     public String makeRequestAndReturnString(String verb, String url, @Nullable String postData, boolean json,
557             @Nullable Map<String, String> customHeaders) throws IOException, URISyntaxException, InterruptedException {
558         HttpsURLConnection connection = makeRequest(verb, url, postData, json, true, customHeaders, 3);
559         String result = convertStream(connection);
560         logger.debug("Result of {} {}:{}", verb, url, result);
561         return result;
562     }
563
564     public HttpsURLConnection makeRequest(String verb, String url, @Nullable String postData, boolean json,
565             boolean autoredirect, @Nullable Map<String, String> customHeaders, int badRequestRepeats)
566             throws IOException, URISyntaxException, InterruptedException {
567         String currentUrl = url;
568         int redirectCounter = 0;
569         int retryCounter = 0;
570         // loop for handling redirect and bad request, using automatic redirect is not
571         // possible, because all response headers must be catched
572         while (true) {
573             int code;
574             HttpsURLConnection connection = null;
575             try {
576                 logger.debug("Make request to {}", url);
577                 connection = (HttpsURLConnection) new URL(currentUrl).openConnection();
578                 connection.setRequestMethod(verb);
579                 connection.setRequestProperty("Accept-Language", "en-US");
580                 if (customHeaders == null || !customHeaders.containsKey("User-Agent")) {
581                     connection.setRequestProperty("User-Agent", userAgent);
582                 }
583                 connection.setRequestProperty("Accept-Encoding", "gzip");
584                 connection.setRequestProperty("DNT", "1");
585                 connection.setRequestProperty("Upgrade-Insecure-Requests", "1");
586                 if (customHeaders != null) {
587                     for (String key : customHeaders.keySet()) {
588                         String value = customHeaders.get(key);
589                         if (value != null && !value.isEmpty()) {
590                             connection.setRequestProperty(key, value);
591                         }
592                     }
593                 }
594                 connection.setInstanceFollowRedirects(false);
595
596                 // add cookies
597                 URI uri = connection.getURL().toURI();
598
599                 if (customHeaders == null || !customHeaders.containsKey("Cookie")) {
600                     StringBuilder cookieHeaderBuilder = new StringBuilder();
601                     for (HttpCookie cookie : cookieManager.getCookieStore().get(uri)) {
602                         if (cookieHeaderBuilder.length() > 0) {
603                             cookieHeaderBuilder.append(";");
604                         }
605                         cookieHeaderBuilder.append(cookie.getName());
606                         cookieHeaderBuilder.append("=");
607                         cookieHeaderBuilder.append(cookie.getValue());
608                         if (cookie.getName().equals("csrf")) {
609                             connection.setRequestProperty("csrf", cookie.getValue());
610                         }
611
612                     }
613                     if (cookieHeaderBuilder.length() > 0) {
614                         String cookies = cookieHeaderBuilder.toString();
615                         connection.setRequestProperty("Cookie", cookies);
616                     }
617                 }
618                 if (postData != null) {
619                     logger.debug("{}: {}", verb, postData);
620                     // post data
621                     byte[] postDataBytes = postData.getBytes(StandardCharsets.UTF_8);
622                     int postDataLength = postDataBytes.length;
623
624                     connection.setFixedLengthStreamingMode(postDataLength);
625
626                     if (json) {
627                         connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
628                     } else {
629                         connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
630                     }
631                     connection.setRequestProperty("Content-Length", Integer.toString(postDataLength));
632                     if ("POST".equals(verb)) {
633                         connection.setRequestProperty("Expect", "100-continue");
634                     }
635
636                     connection.setDoOutput(true);
637                     OutputStream outputStream = connection.getOutputStream();
638                     outputStream.write(postDataBytes);
639                     outputStream.close();
640                 }
641                 // handle result
642                 code = connection.getResponseCode();
643                 String location = null;
644
645                 // handle response headers
646                 Map<@Nullable String, List<String>> headerFields = connection.getHeaderFields();
647                 for (Map.Entry<@Nullable String, List<String>> header : headerFields.entrySet()) {
648                     String key = header.getKey();
649                     if (key != null && !key.isEmpty()) {
650                         if (key.equalsIgnoreCase("Set-Cookie")) {
651                             // store cookie
652                             for (String cookieHeader : header.getValue()) {
653                                 if (!cookieHeader.isEmpty()) {
654                                     List<HttpCookie> cookies = HttpCookie.parse(cookieHeader);
655                                     for (HttpCookie cookie : cookies) {
656                                         cookieManager.getCookieStore().add(uri, cookie);
657                                     }
658                                 }
659                             }
660                         }
661                         if (key.equalsIgnoreCase("Location")) {
662                             // get redirect location
663                             location = header.getValue().get(0);
664                             if (!location.isEmpty()) {
665                                 location = uri.resolve(location).toString();
666                                 // check for https
667                                 if (location.toLowerCase().startsWith("http://")) {
668                                     // always use https
669                                     location = "https://" + location.substring(7);
670                                     logger.debug("Redirect corrected to {}", location);
671                                 }
672                             }
673                         }
674                     }
675                 }
676                 if (code == 200) {
677                     logger.debug("Call to {} succeeded", url);
678                     return connection;
679                 } else if (code == 302 && location != null) {
680                     logger.debug("Redirected to {}", location);
681                     redirectCounter++;
682                     if (redirectCounter > 30) {
683                         throw new ConnectionException("Too many redirects");
684                     }
685                     currentUrl = location;
686                     if (autoredirect) {
687                         continue; // repeat with new location
688                     }
689                     return connection;
690                 } else {
691                     logger.debug("Retry call to {}", url);
692                     retryCounter++;
693                     if (retryCounter > badRequestRepeats) {
694                         throw new HttpException(code,
695                                 verb + " url '" + url + "' failed: " + connection.getResponseMessage());
696                     }
697                     Thread.sleep(2000);
698                 }
699             } catch (InterruptedException | InterruptedIOException e) {
700                 if (connection != null) {
701                     connection.disconnect();
702                 }
703                 logger.warn("Unable to wait for next call to {}", url, e);
704                 throw e;
705             } catch (IOException e) {
706                 if (connection != null) {
707                     connection.disconnect();
708                 }
709                 logger.warn("Request to url '{}' fails with unknown error", url, e);
710                 throw e;
711             } catch (Exception e) {
712                 if (connection != null) {
713                     connection.disconnect();
714                 }
715                 throw e;
716             }
717         }
718     }
719
720     public String registerConnectionAsApp(String oAutRedirectUrl)
721             throws ConnectionException, IOException, URISyntaxException, InterruptedException {
722         URI oAutRedirectUri = new URI(oAutRedirectUrl);
723
724         Map<String, String> queryParameters = new LinkedHashMap<>();
725         String query = oAutRedirectUri.getQuery();
726         String[] pairs = query.split("&");
727         for (String pair : pairs) {
728             int idx = pair.indexOf("=");
729             queryParameters.put(URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8.name()),
730                     URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8.name()));
731         }
732         String accessToken = queryParameters.get("openid.oa2.access_token");
733
734         Map<String, String> cookieMap = new HashMap<>();
735
736         List<JsonWebSiteCookie> webSiteCookies = new ArrayList<>();
737         for (HttpCookie cookie : getSessionCookies("https://www.amazon.com")) {
738             cookieMap.put(cookie.getName(), cookie.getValue());
739             webSiteCookies.add(new JsonWebSiteCookie(cookie.getName(), cookie.getValue()));
740         }
741
742         JsonWebSiteCookie[] webSiteCookiesArray = new JsonWebSiteCookie[webSiteCookies.size()];
743         webSiteCookiesArray = webSiteCookies.toArray(webSiteCookiesArray);
744
745         JsonRegisterAppRequest registerAppRequest = new JsonRegisterAppRequest(serial, accessToken, frc,
746                 webSiteCookiesArray);
747         String registerAppRequestJson = gson.toJson(registerAppRequest);
748
749         HashMap<String, String> registerHeaders = new HashMap<>();
750         registerHeaders.put("x-amzn-identity-auth-domain", "api.amazon.com");
751
752         String registerAppResultJson = makeRequestAndReturnString("POST", "https://api.amazon.com/auth/register",
753                 registerAppRequestJson, true, registerHeaders);
754         JsonRegisterAppResponse registerAppResponse = parseJson(registerAppResultJson, JsonRegisterAppResponse.class);
755
756         if (registerAppResponse == null) {
757             throw new ConnectionException("Error: No response received from register application");
758         }
759         Response response = registerAppResponse.response;
760         if (response == null) {
761             throw new ConnectionException("Error: No response received from register application");
762         }
763         Success success = response.success;
764         if (success == null) {
765             throw new ConnectionException("Error: No success received from register application");
766         }
767         Tokens tokens = success.tokens;
768         if (tokens == null) {
769             throw new ConnectionException("Error: No tokens received from register application");
770         }
771         Bearer bearer = tokens.bearer;
772         if (bearer == null) {
773             throw new ConnectionException("Error: No bearer received from register application");
774         }
775         String refreshToken = bearer.refreshToken;
776         this.refreshToken = refreshToken;
777         if (refreshToken == null || refreshToken.isEmpty()) {
778             throw new ConnectionException("Error: No refresh token received");
779         }
780         try {
781             exchangeToken();
782             // Check which is the owner domain
783             String usersMeResponseJson = makeRequestAndReturnString("GET",
784                     "https://alexa.amazon.com/api/users/me?platform=ios&version=2.2.223830.0", null, false, null);
785             JsonUsersMeResponse usersMeResponse = parseJson(usersMeResponseJson, JsonUsersMeResponse.class);
786             if (usersMeResponse == null) {
787                 throw new IllegalArgumentException("Received no response on me-request");
788             }
789             URI uri = new URI(usersMeResponse.marketPlaceDomainName);
790             String host = uri.getHost();
791
792             // Switch to owner domain
793             setAmazonSite(host);
794             exchangeToken();
795             tryGetBootstrap();
796         } catch (Exception e) {
797             logout();
798             throw e;
799         }
800         String deviceName = null;
801         Extensions extensions = success.extensions;
802         if (extensions != null) {
803             DeviceInfo deviceInfo = extensions.deviceInfo;
804             if (deviceInfo != null) {
805                 deviceName = deviceInfo.deviceName;
806             }
807         }
808         if (deviceName == null) {
809             deviceName = "Unknown";
810         }
811         this.deviceName = deviceName;
812         return deviceName;
813     }
814
815     private void exchangeToken() throws IOException, URISyntaxException, InterruptedException {
816         this.renewTime = 0;
817         String cookiesJson = "{\"cookies\":{\"." + getAmazonSite() + "\":[]}}";
818         String cookiesBase64 = Base64.getEncoder().encodeToString(cookiesJson.getBytes());
819
820         String exchangePostData = "di.os.name=iOS&app_version=2.2.223830.0&domain=." + getAmazonSite()
821                 + "&source_token=" + URLEncoder.encode(this.refreshToken, "UTF8")
822                 + "&requested_token_type=auth_cookies&source_token_type=refresh_token&di.hw.version=iPhone&di.sdk.version=6.10.0&cookies="
823                 + cookiesBase64 + "&app_name=Amazon%20Alexa&di.os.version=11.4.1";
824
825         HashMap<String, String> exchangeTokenHeader = new HashMap<>();
826         exchangeTokenHeader.put("Cookie", "");
827
828         String exchangeTokenJson = makeRequestAndReturnString("POST",
829                 "https://www." + getAmazonSite() + "/ap/exchangetoken", exchangePostData, false, exchangeTokenHeader);
830         JsonExchangeTokenResponse exchangeTokenResponse = Objects
831                 .requireNonNull(gson.fromJson(exchangeTokenJson, JsonExchangeTokenResponse.class));
832
833         org.openhab.binding.amazonechocontrol.internal.jsons.JsonExchangeTokenResponse.Response response = exchangeTokenResponse.response;
834         if (response != null) {
835             org.openhab.binding.amazonechocontrol.internal.jsons.JsonExchangeTokenResponse.Tokens tokens = response.tokens;
836             if (tokens != null) {
837                 Map<String, Cookie[]> cookiesMap = tokens.cookies;
838                 if (cookiesMap != null) {
839                     for (String domain : cookiesMap.keySet()) {
840                         Cookie[] cookies = cookiesMap.get(domain);
841                         if (cookies != null) {
842                             for (Cookie cookie : cookies) {
843                                 if (cookie != null) {
844                                     HttpCookie httpCookie = new HttpCookie(cookie.name, cookie.value);
845                                     httpCookie.setPath(cookie.path);
846                                     httpCookie.setDomain(domain);
847                                     Boolean secure = cookie.secure;
848                                     if (secure != null) {
849                                         httpCookie.setSecure(secure);
850                                     }
851                                     this.cookieManager.getCookieStore().add(null, httpCookie);
852                                 }
853                             }
854                         }
855                     }
856                 }
857             }
858         }
859         if (!verifyLogin()) {
860             throw new ConnectionException("Verify login failed after token exchange");
861         }
862         this.renewTime = (long) (System.currentTimeMillis() + Connection.EXPIRES_IN * 1000d / 0.8d); // start renew at
863     }
864
865     public boolean checkRenewSession() throws URISyntaxException, IOException, InterruptedException {
866         if (System.currentTimeMillis() >= this.renewTime) {
867             String renewTokenPostData = "app_name=Amazon%20Alexa&app_version=2.2.223830.0&di.sdk.version=6.10.0&source_token="
868                     + URLEncoder.encode(refreshToken, StandardCharsets.UTF_8.name())
869                     + "&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&current_version=6.10.0";
870             String renewTokenResponseJson = makeRequestAndReturnString("POST", "https://api.amazon.com/auth/token",
871                     renewTokenPostData, false, null);
872             parseJson(renewTokenResponseJson, JsonRenewTokenResponse.class);
873
874             exchangeToken();
875             return true;
876         }
877         return false;
878     }
879
880     public boolean getIsLoggedIn() {
881         return loginTime != null;
882     }
883
884     public String getLoginPage() throws IOException, URISyntaxException, InterruptedException {
885         // clear session data
886         logout();
887
888         logger.debug("Start Login to {}", alexaServer);
889
890         if (!checkDeviceIdIsValid(deviceId)) {
891             deviceId = generateDeviceId();
892             logger.debug("Generating new device id (old device id had invalid format).");
893         }
894
895         String mapMdJson = "{\"device_user_dictionary\":[],\"device_registration_data\":{\"software_version\":\"1\"},\"app_identifier\":{\"app_version\":\"2.2.223830\",\"bundle_id\":\"com.amazon.echo\"}}";
896         String mapMdCookie = Base64.getEncoder().encodeToString(mapMdJson.getBytes());
897
898         cookieManager.getCookieStore().add(new URI("https://www.amazon.com"), new HttpCookie("map-md", mapMdCookie));
899         cookieManager.getCookieStore().add(new URI("https://www.amazon.com"), new HttpCookie("frc", frc));
900
901         Map<String, String> customHeaders = new HashMap<>();
902         customHeaders.put("authority", "www.amazon.com");
903         String loginFormHtml = makeRequestAndReturnString("GET", "https://www.amazon.com"
904                 + "/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:"
905                 + deviceId
906                 + "&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",
907                 null, false, customHeaders);
908
909         logger.debug("Received login form {}", loginFormHtml);
910         return loginFormHtml;
911     }
912
913     public boolean verifyLogin() throws IOException, URISyntaxException, InterruptedException {
914         if (this.refreshToken == null) {
915             return false;
916         }
917         Authentication authentication = tryGetBootstrap();
918         if (authentication != null && authentication.authenticated) {
919             verifyTime = new Date();
920             if (loginTime == null) {
921                 loginTime = verifyTime;
922             }
923             return true;
924         }
925         return false;
926     }
927
928     public List<HttpCookie> getSessionCookies() {
929         try {
930             return cookieManager.getCookieStore().get(new URI(alexaServer));
931         } catch (URISyntaxException e) {
932             return new ArrayList<>();
933         }
934     }
935
936     public List<HttpCookie> getSessionCookies(String server) {
937         try {
938             return cookieManager.getCookieStore().get(new URI(server));
939         } catch (URISyntaxException e) {
940             return new ArrayList<>();
941         }
942     }
943
944     private void replaceTimer(TimerType type, @Nullable ScheduledFuture<?> newTimer) {
945         timers.compute(type, (timerType, oldTimer) -> {
946             if (oldTimer != null) {
947                 oldTimer.cancel(true);
948             }
949             return newTimer;
950         });
951     }
952
953     public void logout() {
954         cookieManager.getCookieStore().removeAll();
955         // reset all members
956         refreshToken = null;
957         loginTime = null;
958         verifyTime = null;
959         deviceName = null;
960
961         replaceTimer(TimerType.ANNOUNCEMENT, null);
962         announcements.clear();
963         replaceTimer(TimerType.TTS, null);
964         textToSpeeches.clear();
965         replaceTimer(TimerType.VOLUME, null);
966         volumes.clear();
967         replaceTimer(TimerType.DEVICES, null);
968         textCommands.clear();
969         replaceTimer(TimerType.TTS, null);
970
971         devices.values().forEach((queueObjects) -> {
972             queueObjects.forEach((queueObject) -> {
973                 Future<?> future = queueObject.future;
974                 if (future != null) {
975                     future.cancel(true);
976                     queueObject.future = null;
977                 }
978             });
979         });
980     }
981
982     // parser
983     private <T> @Nullable T parseJson(String json, Class<T> type) throws JsonSyntaxException, IllegalStateException {
984         try {
985             return gson.fromJson(json, type);
986         } catch (JsonParseException | IllegalStateException e) {
987             logger.warn("Parsing json failed: {}", json, e);
988             throw e;
989         }
990     }
991
992     // commands and states
993     public WakeWord[] getWakeWords() {
994         String json;
995         try {
996             json = makeRequestAndReturnString(alexaServer + "/api/wake-word?cached=true");
997             JsonWakeWords wakeWords = parseJson(json, JsonWakeWords.class);
998             if (wakeWords != null) {
999                 WakeWord[] result = wakeWords.wakeWords;
1000                 if (result != null) {
1001                     return result;
1002                 }
1003             }
1004         } catch (IOException | URISyntaxException | InterruptedException e) {
1005             logger.info("getting wakewords failed", e);
1006         }
1007         return new WakeWord[0];
1008     }
1009
1010     public List<SmartHomeBaseDevice> getSmarthomeDeviceList()
1011             throws IOException, URISyntaxException, InterruptedException {
1012         try {
1013             String json = makeRequestAndReturnString(alexaServer + "/api/phoenix");
1014             logger.debug("getSmartHomeDevices result: {}", json);
1015
1016             JsonNetworkDetails networkDetails = parseJson(json, JsonNetworkDetails.class);
1017             if (networkDetails == null) {
1018                 throw new IllegalArgumentException("received no response on network detail request");
1019             }
1020             Object jsonObject = gson.fromJson(networkDetails.networkDetail, Object.class);
1021             List<SmartHomeBaseDevice> result = new ArrayList<>();
1022             searchSmartHomeDevicesRecursive(jsonObject, result);
1023
1024             return result;
1025         } catch (Exception e) {
1026             logger.warn("getSmartHomeDevices fails: {}", e.getMessage());
1027             throw e;
1028         }
1029     }
1030
1031     private void searchSmartHomeDevicesRecursive(@Nullable Object jsonNode, List<SmartHomeBaseDevice> devices) {
1032         if (jsonNode instanceof Map) {
1033             @SuppressWarnings("rawtypes")
1034             Map<String, Object> map = (Map) jsonNode;
1035             if (map.containsKey("entityId") && map.containsKey("friendlyName") && map.containsKey("actions")) {
1036                 // device node found, create type element and add it to the results
1037                 JsonElement element = gson.toJsonTree(jsonNode);
1038                 SmartHomeDevice shd = parseJson(element.toString(), SmartHomeDevice.class);
1039                 if (shd != null) {
1040                     devices.add(shd);
1041                 }
1042             } else if (map.containsKey("applianceGroupName")) {
1043                 JsonElement element = gson.toJsonTree(jsonNode);
1044                 SmartHomeGroup shg = parseJson(element.toString(), SmartHomeGroup.class);
1045                 if (shg != null) {
1046                     devices.add(shg);
1047                 }
1048             } else {
1049                 map.values().forEach(value -> searchSmartHomeDevicesRecursive(value, devices));
1050             }
1051         }
1052     }
1053
1054     public List<Device> getDeviceList() throws IOException, URISyntaxException, InterruptedException {
1055         String json = getDeviceListJson();
1056         JsonDevices devices = parseJson(json, JsonDevices.class);
1057         if (devices != null) {
1058             Device[] result = devices.devices;
1059             if (result != null) {
1060                 return new ArrayList<>(Arrays.asList(result));
1061             }
1062         }
1063         return Collections.emptyList();
1064     }
1065
1066     public String getDeviceListJson() throws IOException, URISyntaxException, InterruptedException {
1067         String json = makeRequestAndReturnString(alexaServer + "/api/devices-v2/device?cached=false");
1068         return json;
1069     }
1070
1071     public Map<String, JsonArray> getSmartHomeDeviceStatesJson(Set<String> applianceIds)
1072             throws IOException, URISyntaxException, InterruptedException {
1073         JsonObject requestObject = new JsonObject();
1074         JsonArray stateRequests = new JsonArray();
1075         for (String applianceId : applianceIds) {
1076             JsonObject stateRequest = new JsonObject();
1077             stateRequest.addProperty("entityId", applianceId);
1078             stateRequest.addProperty("entityType", "APPLIANCE");
1079             stateRequests.add(stateRequest);
1080         }
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);
1085
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 applicanceId = entity.get("entityId").getAsString();
1093             JsonElement capabilityState = deviceStateObject.get("capabilityStates");
1094             if (capabilityState != null && capabilityState.isJsonArray()) {
1095                 result.put(applicanceId, capabilityState.getAsJsonArray());
1096             }
1097         }
1098         return result;
1099     }
1100
1101     public @Nullable JsonPlayerState getPlayer(Device device)
1102             throws IOException, URISyntaxException, InterruptedException {
1103         String json = makeRequestAndReturnString(alexaServer + "/api/np/player?deviceSerialNumber="
1104                 + device.serialNumber + "&deviceType=" + device.deviceType + "&screenWidth=1440");
1105         JsonPlayerState playerState = parseJson(json, JsonPlayerState.class);
1106         return playerState;
1107     }
1108
1109     public @Nullable JsonMediaState getMediaState(Device device)
1110             throws IOException, URISyntaxException, InterruptedException {
1111         String json = makeRequestAndReturnString(alexaServer + "/api/media/state?deviceSerialNumber="
1112                 + device.serialNumber + "&deviceType=" + device.deviceType);
1113         JsonMediaState mediaState = parseJson(json, JsonMediaState.class);
1114         return mediaState;
1115     }
1116
1117     public Activity[] getActivities(int number, @Nullable Long startTime) {
1118         String json;
1119         try {
1120             json = makeRequestAndReturnString(alexaServer + "/api/activities?startTime="
1121                     + (startTime != null ? startTime : "") + "&size=" + number + "&offset=1");
1122             JsonActivities activities = parseJson(json, JsonActivities.class);
1123             if (activities != null) {
1124                 Activity[] activiesArray = activities.activities;
1125                 if (activiesArray != null) {
1126                     return activiesArray;
1127                 }
1128             }
1129         } catch (IOException | URISyntaxException | InterruptedException e) {
1130             logger.info("getting activities failed", e);
1131         }
1132         return new Activity[0];
1133     }
1134
1135     public @Nullable JsonBluetoothStates getBluetoothConnectionStates() {
1136         String json;
1137         try {
1138             json = makeRequestAndReturnString(alexaServer + "/api/bluetooth?cached=true");
1139         } catch (IOException | URISyntaxException | InterruptedException e) {
1140             logger.debug("failed to get bluetooth state: {}", e.getMessage());
1141             return new JsonBluetoothStates();
1142         }
1143         JsonBluetoothStates bluetoothStates = parseJson(json, JsonBluetoothStates.class);
1144         return bluetoothStates;
1145     }
1146
1147     public @Nullable JsonPlaylists getPlaylists(Device device)
1148             throws IOException, URISyntaxException, InterruptedException {
1149         String json = makeRequestAndReturnString(
1150                 alexaServer + "/api/cloudplayer/playlists?deviceSerialNumber=" + device.serialNumber + "&deviceType="
1151                         + device.deviceType + "&mediaOwnerCustomerId=" + getCustomerId(device.deviceOwnerCustomerId));
1152         JsonPlaylists playlists = parseJson(json, JsonPlaylists.class);
1153         return playlists;
1154     }
1155
1156     public void command(Device device, String command) throws IOException, URISyntaxException, InterruptedException {
1157         String url = alexaServer + "/api/np/command?deviceSerialNumber=" + device.serialNumber + "&deviceType="
1158                 + device.deviceType;
1159         makeRequest("POST", url, command, true, true, null, 0);
1160     }
1161
1162     public void smartHomeCommand(String entityId, String action) throws IOException, InterruptedException {
1163         smartHomeCommand(entityId, action, null, null);
1164     }
1165
1166     public void smartHomeCommand(String entityId, String action, @Nullable String property, @Nullable Object value)
1167             throws IOException, InterruptedException {
1168         String url = alexaServer + "/api/phoenix/state";
1169
1170         JsonObject json = new JsonObject();
1171         JsonArray controlRequests = new JsonArray();
1172         JsonObject controlRequest = new JsonObject();
1173         controlRequest.addProperty("entityId", entityId);
1174         controlRequest.addProperty("entityType", "APPLIANCE");
1175         JsonObject parameters = new JsonObject();
1176         parameters.addProperty("action", action);
1177         if (property != null) {
1178             if (value instanceof QuantityType<?>) {
1179                 parameters.addProperty(property + ".value", ((QuantityType<?>) value).floatValue());
1180                 parameters.addProperty(property + ".scale",
1181                         ((QuantityType<?>) value).getUnit().equals(SIUnits.CELSIUS) ? "celsius" : "fahrenheit");
1182             } else if (value instanceof Boolean) {
1183                 parameters.addProperty(property, (boolean) value);
1184             } else if (value instanceof String) {
1185                 parameters.addProperty(property, (String) value);
1186             } else if (value instanceof Number) {
1187                 parameters.addProperty(property, (Number) value);
1188             } else if (value instanceof Character) {
1189                 parameters.addProperty(property, (Character) value);
1190             } else if (value instanceof JsonElement) {
1191                 parameters.add(property, (JsonElement) value);
1192             }
1193         }
1194         controlRequest.add("parameters", parameters);
1195         controlRequests.add(controlRequest);
1196         json.add("controlRequests", controlRequests);
1197
1198         String requestBody = json.toString();
1199         try {
1200             String resultBody = makeRequestAndReturnString("PUT", url, requestBody, true, null);
1201             logger.trace("Request '{}' resulted in '{}", requestBody, resultBody);
1202             JsonObject result = parseJson(resultBody, JsonObject.class);
1203             if (result != null) {
1204                 JsonElement errors = result.get("errors");
1205                 if (errors != null && errors.isJsonArray()) {
1206                     JsonArray errorList = errors.getAsJsonArray();
1207                     if (errorList.size() > 0) {
1208                         logger.warn("Smart home device command failed. The request '{}' resulted in error(s): {}",
1209                                 requestBody, StreamSupport.stream(errorList.spliterator(), false)
1210                                         .map(JsonElement::toString).collect(Collectors.joining(" / ")));
1211                     }
1212                 }
1213             }
1214         } catch (URISyntaxException e) {
1215             logger.warn("URL '{}' has invalid format for request '{}': {}", url, requestBody, e.getMessage());
1216         }
1217     }
1218
1219     public void notificationVolume(Device device, int volume)
1220             throws IOException, URISyntaxException, InterruptedException {
1221         String url = alexaServer + "/api/device-notification-state/" + device.deviceType + "/" + device.softwareVersion
1222                 + "/" + device.serialNumber;
1223         String command = "{\"deviceSerialNumber\":\"" + device.serialNumber + "\",\"deviceType\":\"" + device.deviceType
1224                 + "\",\"softwareVersion\":\"" + device.softwareVersion + "\",\"volumeLevel\":" + volume + "}";
1225         makeRequest("PUT", url, command, true, true, null, 0);
1226     }
1227
1228     public void ascendingAlarm(Device device, boolean ascendingAlarm)
1229             throws IOException, URISyntaxException, InterruptedException {
1230         String url = alexaServer + "/api/ascending-alarm/" + device.serialNumber;
1231         String command = "{\"ascendingAlarmEnabled\":" + (ascendingAlarm ? "true" : "false")
1232                 + ",\"deviceSerialNumber\":\"" + device.serialNumber + "\",\"deviceType\":\"" + device.deviceType
1233                 + "\",\"deviceAccountId\":null}";
1234         makeRequest("PUT", url, command, true, true, null, 0);
1235     }
1236
1237     public DeviceNotificationState[] getDeviceNotificationStates() {
1238         String json;
1239         try {
1240             json = makeRequestAndReturnString(alexaServer + "/api/device-notification-state");
1241             JsonDeviceNotificationState result = parseJson(json, JsonDeviceNotificationState.class);
1242             if (result != null) {
1243                 DeviceNotificationState[] deviceNotificationStates = result.deviceNotificationStates;
1244                 if (deviceNotificationStates != null) {
1245                     return deviceNotificationStates;
1246                 }
1247             }
1248         } catch (IOException | URISyntaxException | InterruptedException e) {
1249             logger.info("Error getting device notification states", e);
1250         }
1251         return new DeviceNotificationState[0];
1252     }
1253
1254     public AscendingAlarmModel[] getAscendingAlarm() {
1255         String json;
1256         try {
1257             json = makeRequestAndReturnString(alexaServer + "/api/ascending-alarm");
1258             JsonAscendingAlarm result = parseJson(json, JsonAscendingAlarm.class);
1259             if (result != null) {
1260                 AscendingAlarmModel[] ascendingAlarmModelList = result.ascendingAlarmModelList;
1261                 if (ascendingAlarmModelList != null) {
1262                     return ascendingAlarmModelList;
1263                 }
1264             }
1265         } catch (IOException | URISyntaxException | InterruptedException e) {
1266             logger.info("Error getting device notification states", e);
1267         }
1268         return new AscendingAlarmModel[0];
1269     }
1270
1271     public void bluetooth(Device device, @Nullable String address)
1272             throws IOException, URISyntaxException, InterruptedException {
1273         if (address == null || address.isEmpty()) {
1274             // disconnect
1275             makeRequest("POST",
1276                     alexaServer + "/api/bluetooth/disconnect-sink/" + device.deviceType + "/" + device.serialNumber, "",
1277                     true, true, null, 0);
1278         } else {
1279             makeRequest("POST",
1280                     alexaServer + "/api/bluetooth/pair-sink/" + device.deviceType + "/" + device.serialNumber,
1281                     "{\"bluetoothDeviceAddress\":\"" + address + "\"}", true, true, null, 0);
1282         }
1283     }
1284
1285     private @Nullable String getCustomerId(@Nullable String defaultId) {
1286         String accountCustomerId = this.accountCustomerId;
1287         return accountCustomerId == null || accountCustomerId.isEmpty() ? defaultId : accountCustomerId;
1288     }
1289
1290     public void playRadio(Device device, @Nullable String stationId)
1291             throws IOException, URISyntaxException, InterruptedException {
1292         if (stationId == null || stationId.isEmpty()) {
1293             command(device, "{\"type\":\"PauseCommand\"}");
1294         } else {
1295             makeRequest("POST",
1296                     alexaServer + "/api/tunein/queue-and-play?deviceSerialNumber=" + device.serialNumber
1297                             + "&deviceType=" + device.deviceType + "&guideId=" + stationId
1298                             + "&contentType=station&callSign=&mediaOwnerCustomerId="
1299                             + getCustomerId(device.deviceOwnerCustomerId),
1300                     "", true, true, null, 0);
1301         }
1302     }
1303
1304     public void playAmazonMusicTrack(Device device, @Nullable String trackId)
1305             throws IOException, URISyntaxException, InterruptedException {
1306         if (trackId == null || trackId.isEmpty()) {
1307             command(device, "{\"type\":\"PauseCommand\"}");
1308         } else {
1309             String command = "{\"trackId\":\"" + trackId + "\",\"playQueuePrime\":true}";
1310             makeRequest("POST",
1311                     alexaServer + "/api/cloudplayer/queue-and-play?deviceSerialNumber=" + device.serialNumber
1312                             + "&deviceType=" + device.deviceType + "&mediaOwnerCustomerId="
1313                             + getCustomerId(device.deviceOwnerCustomerId) + "&shuffle=false",
1314                     command, true, true, null, 0);
1315         }
1316     }
1317
1318     public void playAmazonMusicPlayList(Device device, @Nullable String playListId)
1319             throws IOException, URISyntaxException, InterruptedException {
1320         if (playListId == null || playListId.isEmpty()) {
1321             command(device, "{\"type\":\"PauseCommand\"}");
1322         } else {
1323             String command = "{\"playlistId\":\"" + playListId + "\",\"playQueuePrime\":true}";
1324             makeRequest("POST",
1325                     alexaServer + "/api/cloudplayer/queue-and-play?deviceSerialNumber=" + device.serialNumber
1326                             + "&deviceType=" + device.deviceType + "&mediaOwnerCustomerId="
1327                             + getCustomerId(device.deviceOwnerCustomerId) + "&shuffle=false",
1328                     command, true, true, null, 0);
1329         }
1330     }
1331
1332     public void announcement(Device device, String speak, String bodyText, @Nullable String title,
1333             @Nullable Integer ttsVolume, @Nullable Integer standardVolume) {
1334         String plainSpeak = speak.replaceAll("<.+?>", " ").replaceAll("\\s+", " ").trim();
1335         String plainBody = bodyText.replaceAll("<.+?>", " ").replaceAll("\\s+", " ").trim();
1336
1337         if (plainSpeak.isEmpty() && plainBody.isEmpty()) {
1338             // if there is neither a bodytext nor (except tags) a speaktext, we have nothing to announce
1339             return;
1340         }
1341
1342         // we lock announcements until we have finished adding this one
1343         Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.ANNOUNCEMENT, k -> new ReentrantLock()));
1344         lock.lock();
1345         try {
1346             AnnouncementWrapper announcement = Objects.requireNonNull(announcements.computeIfAbsent(
1347                     Objects.hash(speak, plainBody, title), k -> new AnnouncementWrapper(speak, plainBody, title)));
1348             announcement.devices.add(device);
1349             announcement.ttsVolumes.add(ttsVolume);
1350             announcement.standardVolumes.add(standardVolume);
1351
1352             // schedule an announcement only if it has not been scheduled before
1353             timers.computeIfAbsent(TimerType.ANNOUNCEMENT,
1354                     k -> scheduler.schedule(this::sendAnnouncement, 500, TimeUnit.MILLISECONDS));
1355         } finally {
1356             lock.unlock();
1357         }
1358     }
1359
1360     private void sendAnnouncement() {
1361         // we lock new announcements until we have dispatched everything
1362         Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.ANNOUNCEMENT, k -> new ReentrantLock()));
1363         lock.lock();
1364         try {
1365             Iterator<AnnouncementWrapper> iterator = announcements.values().iterator();
1366             while (iterator.hasNext()) {
1367                 AnnouncementWrapper announcement = iterator.next();
1368                 try {
1369                     List<Device> devices = announcement.devices;
1370                     if (!devices.isEmpty()) {
1371                         JsonAnnouncementContent content = new JsonAnnouncementContent(announcement);
1372
1373                         Map<String, Object> parameters = new HashMap<>();
1374                         parameters.put("expireAfter", "PT5S");
1375                         parameters.put("content", new JsonAnnouncementContent[] { content });
1376                         parameters.put("target", new JsonAnnouncementTarget(devices));
1377
1378                         String customerId = getCustomerId(devices.get(0).deviceOwnerCustomerId);
1379                         if (customerId != null) {
1380                             parameters.put("customerId", customerId);
1381                         }
1382                         executeSequenceCommandWithVolume(devices, "AlexaAnnouncement", parameters,
1383                                 announcement.ttsVolumes, announcement.standardVolumes);
1384                     }
1385                 } catch (Exception e) {
1386                     logger.warn("send announcement fails with unexpected error", e);
1387                 }
1388                 iterator.remove();
1389             }
1390         } finally {
1391             // the timer is done anyway immediately after we unlock
1392             timers.remove(TimerType.ANNOUNCEMENT);
1393             lock.unlock();
1394         }
1395     }
1396
1397     public void textToSpeech(Device device, String text, @Nullable Integer ttsVolume,
1398             @Nullable Integer standardVolume) {
1399         if (text.replaceAll("<.+?>", "").replaceAll("\\s+", " ").trim().isEmpty()) {
1400             return;
1401         }
1402
1403         // we lock TTS until we have finished adding this one
1404         Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.TTS, k -> new ReentrantLock()));
1405         lock.lock();
1406         try {
1407             TextToSpeech textToSpeech = Objects
1408                     .requireNonNull(textToSpeeches.computeIfAbsent(Objects.hash(text), k -> new TextToSpeech(text)));
1409             textToSpeech.devices.add(device);
1410             textToSpeech.ttsVolumes.add(ttsVolume);
1411             textToSpeech.standardVolumes.add(standardVolume);
1412             // schedule a TTS only if it has not been scheduled before
1413             timers.computeIfAbsent(TimerType.TTS,
1414                     k -> scheduler.schedule(this::sendTextToSpeech, 500, TimeUnit.MILLISECONDS));
1415         } finally {
1416             lock.unlock();
1417         }
1418     }
1419
1420     private void sendTextToSpeech() {
1421         // we lock new TTS until we have dispatched everything
1422         Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.TTS, k -> new ReentrantLock()));
1423         lock.lock();
1424         try {
1425             Iterator<TextToSpeech> iterator = textToSpeeches.values().iterator();
1426             while (iterator.hasNext()) {
1427                 TextToSpeech textToSpeech = iterator.next();
1428                 try {
1429                     List<Device> devices = textToSpeech.devices;
1430                     if (!devices.isEmpty()) {
1431                         String text = textToSpeech.text;
1432                         Map<String, Object> parameters = Map.of("textToSpeak", text);
1433                         executeSequenceCommandWithVolume(devices, "Alexa.Speak", parameters, textToSpeech.ttsVolumes,
1434                                 textToSpeech.standardVolumes);
1435                     }
1436                 } catch (Exception e) {
1437                     logger.warn("send textToSpeech fails with unexpected error", e);
1438                 }
1439                 iterator.remove();
1440             }
1441         } finally {
1442             // the timer is done anyway immediately after we unlock
1443             timers.remove(TimerType.TTS);
1444             lock.unlock();
1445         }
1446     }
1447
1448     public void textCommand(Device device, String text, @Nullable Integer ttsVolume, @Nullable Integer standardVolume) {
1449         if (text.replaceAll("<.+?>", "").replaceAll("\\s+", " ").trim().isEmpty()) {
1450             return;
1451         }
1452
1453         // we lock TextCommands until we have finished adding this one
1454         Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.TEXT_COMMAND, k -> new ReentrantLock()));
1455         lock.lock();
1456         try {
1457             TextCommand textCommand = Objects
1458                     .requireNonNull(textCommands.computeIfAbsent(Objects.hash(text), k -> new TextCommand(text)));
1459             textCommand.devices.add(device);
1460             textCommand.ttsVolumes.add(ttsVolume);
1461             textCommand.standardVolumes.add(standardVolume);
1462             // schedule a TextCommand only if it has not been scheduled before
1463             timers.computeIfAbsent(TimerType.TEXT_COMMAND,
1464                     k -> scheduler.schedule(this::sendTextCommand, 500, TimeUnit.MILLISECONDS));
1465         } finally {
1466             lock.unlock();
1467         }
1468     }
1469
1470     private synchronized void sendTextCommand() {
1471         // we lock new TTS until we have dispatched everything
1472         Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.TEXT_COMMAND, k -> new ReentrantLock()));
1473         lock.lock();
1474
1475         try {
1476             Iterator<TextCommand> iterator = textCommands.values().iterator();
1477             while (iterator.hasNext()) {
1478                 TextCommand textCommand = iterator.next();
1479                 try {
1480                     List<Device> devices = textCommand.devices;
1481                     if (!devices.isEmpty()) {
1482                         String text = textCommand.text;
1483                         Map<String, Object> parameters = Map.of("text", text);
1484                         executeSequenceCommandWithVolume(devices, "Alexa.TextCommand", parameters,
1485                                 textCommand.ttsVolumes, textCommand.standardVolumes);
1486                     }
1487                 } catch (Exception e) {
1488                     logger.warn("send textCommand fails with unexpected error", e);
1489                 }
1490                 iterator.remove();
1491             }
1492         } finally {
1493             // the timer is done anyway immediately after we unlock
1494             timers.remove(TimerType.TEXT_COMMAND);
1495             lock.unlock();
1496         }
1497     }
1498
1499     public void volume(Device device, int vol) {
1500         // we lock volume until we have finished adding this one
1501         Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.VOLUME, k -> new ReentrantLock()));
1502         lock.lock();
1503         try {
1504             Volume volume = Objects.requireNonNull(volumes.computeIfAbsent(vol, k -> new Volume(vol)));
1505             volume.devices.add(device);
1506             volume.volumes.add(vol);
1507             // schedule a TTS only if it has not been scheduled before
1508             timers.computeIfAbsent(TimerType.VOLUME,
1509                     k -> scheduler.schedule(this::sendVolume, 500, TimeUnit.MILLISECONDS));
1510         } finally {
1511             lock.unlock();
1512         }
1513     }
1514
1515     private void sendVolume() {
1516         // we lock new volume until we have dispatched everything
1517         Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.VOLUME, k -> new ReentrantLock()));
1518         lock.lock();
1519         try {
1520             Iterator<Volume> iterator = volumes.values().iterator();
1521             while (iterator.hasNext()) {
1522                 Volume volume = iterator.next();
1523                 try {
1524                     List<Device> devices = volume.devices;
1525                     if (!devices.isEmpty()) {
1526                         executeSequenceCommandWithVolume(devices, null, Map.of(), volume.volumes, List.of());
1527                     }
1528                 } catch (Exception e) {
1529                     logger.warn("send volume fails with unexpected error", e);
1530                 }
1531                 iterator.remove();
1532             }
1533         } finally {
1534             // the timer is done anyway immediately after we unlock
1535             timers.remove(TimerType.VOLUME);
1536             lock.unlock();
1537         }
1538     }
1539
1540     private void executeSequenceCommandWithVolume(List<Device> devices, @Nullable String command,
1541             Map<String, Object> parameters, List<@Nullable Integer> ttsVolumes,
1542             List<@Nullable Integer> standardVolumes) {
1543         JsonArray serialNodesToExecute = new JsonArray();
1544         JsonArray ttsVolumeNodesToExecute = new JsonArray();
1545         for (int i = 0; i < devices.size(); i++) {
1546             Integer ttsVolume = ttsVolumes.size() > i ? ttsVolumes.get(i) : null;
1547             Integer standardVolume = standardVolumes.size() > i ? standardVolumes.get(i) : null;
1548             if (ttsVolume != null && (standardVolume != null || !ttsVolume.equals(standardVolume))) {
1549                 ttsVolumeNodesToExecute.add(
1550                         createExecutionNode(devices.get(i), "Alexa.DeviceControls.Volume", Map.of("value", ttsVolume)));
1551             }
1552         }
1553         if (ttsVolumeNodesToExecute.size() > 0) {
1554             JsonObject parallelNodesToExecute = new JsonObject();
1555             parallelNodesToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1556             parallelNodesToExecute.add("nodesToExecute", ttsVolumeNodesToExecute);
1557             serialNodesToExecute.add(parallelNodesToExecute);
1558         }
1559
1560         if (command != null && !parameters.isEmpty()) {
1561             JsonArray commandNodesToExecute = new JsonArray();
1562             if ("Alexa.Speak".equals(command) || "Alexa.TextCommand".equals(command)) {
1563                 for (Device device : devices) {
1564                     commandNodesToExecute.add(createExecutionNode(device, command, parameters));
1565                 }
1566             } else {
1567                 commandNodesToExecute.add(createExecutionNode(devices.get(0), command, parameters));
1568             }
1569             if (commandNodesToExecute.size() > 0) {
1570                 JsonObject parallelNodesToExecute = new JsonObject();
1571                 parallelNodesToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1572                 parallelNodesToExecute.add("nodesToExecute", commandNodesToExecute);
1573                 serialNodesToExecute.add(parallelNodesToExecute);
1574             }
1575         }
1576
1577         JsonArray standardVolumeNodesToExecute = new JsonArray();
1578         for (int i = 0; i < devices.size(); i++) {
1579             Integer ttsVolume = ttsVolumes.size() > i ? ttsVolumes.get(i) : null;
1580             Integer standardVolume = standardVolumes.size() > i ? standardVolumes.get(i) : null;
1581             if (ttsVolume != null && standardVolume != null && !ttsVolume.equals(standardVolume)) {
1582                 standardVolumeNodesToExecute.add(createExecutionNode(devices.get(i), "Alexa.DeviceControls.Volume",
1583                         Map.of("value", standardVolume)));
1584             }
1585         }
1586         if (standardVolumeNodesToExecute.size() > 0) {
1587             JsonObject parallelNodesToExecute = new JsonObject();
1588             parallelNodesToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1589             parallelNodesToExecute.add("nodesToExecute", standardVolumeNodesToExecute);
1590             serialNodesToExecute.add(parallelNodesToExecute);
1591         }
1592
1593         if (serialNodesToExecute.size() > 0) {
1594             executeSequenceNodes(devices, serialNodesToExecute, false);
1595         }
1596     }
1597
1598     // commands: Alexa.Weather.Play, Alexa.Traffic.Play, Alexa.FlashBriefing.Play,
1599     // Alexa.GoodMorning.Play,
1600     // Alexa.SingASong.Play, Alexa.TellStory.Play, Alexa.Speak (textToSpeach)
1601     public void executeSequenceCommand(Device device, String command, Map<String, Object> parameters) {
1602         JsonObject nodeToExecute = createExecutionNode(device, command, parameters);
1603         executeSequenceNode(List.of(device), nodeToExecute);
1604     }
1605
1606     private void executeSequenceNode(List<Device> devices, JsonObject nodeToExecute) {
1607         QueueObject queueObject = new QueueObject();
1608         queueObject.devices = devices;
1609         queueObject.nodeToExecute = nodeToExecute;
1610         String serialNumbers = "";
1611         for (Device device : devices) {
1612             String serialNumber = device.serialNumber;
1613             if (serialNumber != null) {
1614                 Objects.requireNonNull(this.devices.computeIfAbsent(serialNumber, k -> new LinkedBlockingQueue<>()))
1615                         .offer(queueObject);
1616                 serialNumbers = serialNumbers + device.serialNumber + " ";
1617             }
1618         }
1619         logger.debug("added {} device {}", queueObject.hashCode(), serialNumbers);
1620     }
1621
1622     private void handleExecuteSequenceNode() {
1623         Lock lock = Objects.requireNonNull(locks.computeIfAbsent(TimerType.DEVICES, k -> new ReentrantLock()));
1624         if (lock.tryLock()) {
1625             try {
1626                 for (String serialNumber : devices.keySet()) {
1627                     LinkedBlockingQueue<QueueObject> queueObjects = devices.get(serialNumber);
1628                     if (queueObjects != null) {
1629                         QueueObject queueObject = queueObjects.peek();
1630                         if (queueObject != null) {
1631                             Future<?> future = queueObject.future;
1632                             if (future == null || future.isDone()) {
1633                                 boolean execute = true;
1634                                 String serial = "";
1635                                 for (Device tmpDevice : queueObject.devices) {
1636                                     if (!serialNumber.equals(tmpDevice.serialNumber)) {
1637                                         LinkedBlockingQueue<QueueObject> tmpQueueObjects = devices
1638                                                 .get(tmpDevice.serialNumber);
1639                                         if (tmpQueueObjects != null) {
1640                                             QueueObject tmpQueueObject = tmpQueueObjects.peek();
1641                                             Future<?> tmpFuture = tmpQueueObject.future;
1642                                             if (!queueObject.equals(tmpQueueObject)
1643                                                     || (tmpFuture != null && !tmpFuture.isDone())) {
1644                                                 execute = false;
1645                                                 break;
1646                                             }
1647                                             serial = serial + tmpDevice.serialNumber + " ";
1648                                         }
1649                                     }
1650                                 }
1651                                 if (execute) {
1652                                     queueObject.future = scheduler.submit(() -> queuedExecuteSequenceNode(queueObject));
1653                                     logger.debug("thread {} device {}", queueObject.hashCode(), serial);
1654                                 }
1655                             }
1656                         }
1657                     }
1658                 }
1659             } finally {
1660                 lock.unlock();
1661             }
1662         }
1663     }
1664
1665     private void queuedExecuteSequenceNode(QueueObject queueObject) {
1666         JsonObject nodeToExecute = queueObject.nodeToExecute;
1667         ExecutionNodeObject executionNodeObject = getExecutionNodeObject(nodeToExecute);
1668         if (executionNodeObject == null) {
1669             logger.debug("executionNodeObject empty, removing without execution");
1670             removeObjectFromQueueAfterExecutionCompletion(queueObject);
1671             return;
1672         }
1673         List<String> types = executionNodeObject.types;
1674         long delay = 0;
1675         if (types.contains("Alexa.DeviceControls.Volume")) {
1676             delay += 2000;
1677         }
1678         if (types.contains("Announcement")) {
1679             delay += 3000;
1680         } else {
1681             delay += 2000;
1682         }
1683         try {
1684             JsonObject sequenceJson = new JsonObject();
1685             sequenceJson.addProperty("@type", "com.amazon.alexa.behaviors.model.Sequence");
1686             sequenceJson.add("startNode", nodeToExecute);
1687
1688             JsonStartRoutineRequest request = new JsonStartRoutineRequest();
1689             request.sequenceJson = gson.toJson(sequenceJson);
1690             String json = gson.toJson(request);
1691
1692             Map<String, String> headers = new HashMap<>();
1693             headers.put("Routines-Version", "1.1.218665");
1694
1695             String text = executionNodeObject.text;
1696             if (text != null) {
1697                 text = text.replaceAll("<.+?>", " ").replaceAll("\\s+", " ").trim();
1698                 delay += text.length() * 150;
1699             }
1700
1701             makeRequest("POST", alexaServer + "/api/behaviors/preview", json, true, true, null, 3);
1702
1703             Thread.sleep(delay);
1704         } catch (IOException | URISyntaxException | InterruptedException e) {
1705             logger.warn("execute sequence node fails with unexpected error", e);
1706         } finally {
1707             removeObjectFromQueueAfterExecutionCompletion(queueObject);
1708         }
1709     }
1710
1711     private void removeObjectFromQueueAfterExecutionCompletion(QueueObject queueObject) {
1712         String serial = "";
1713         for (Device device : queueObject.devices) {
1714             String serialNumber = device.serialNumber;
1715             if (serialNumber != null) {
1716                 LinkedBlockingQueue<?> queue = devices.get(serialNumber);
1717                 if (queue != null) {
1718                     queue.remove(queueObject);
1719                 }
1720                 serial = serial + serialNumber + " ";
1721             }
1722         }
1723         logger.debug("removed {} device {}", queueObject.hashCode(), serial);
1724     }
1725
1726     private void executeSequenceNodes(List<Device> devices, JsonArray nodesToExecute, boolean parallel) {
1727         JsonObject serialNode = new JsonObject();
1728         if (parallel) {
1729             serialNode.addProperty("@type", "com.amazon.alexa.behaviors.model.ParallelNode");
1730         } else {
1731             serialNode.addProperty("@type", "com.amazon.alexa.behaviors.model.SerialNode");
1732         }
1733
1734         serialNode.add("nodesToExecute", nodesToExecute);
1735
1736         executeSequenceNode(devices, serialNode);
1737     }
1738
1739     private JsonObject createExecutionNode(@Nullable Device device, String command, Map<String, Object> parameters) {
1740         JsonObject operationPayload = new JsonObject();
1741         if (device != null) {
1742             operationPayload.addProperty("deviceType", device.deviceType);
1743             operationPayload.addProperty("deviceSerialNumber", device.serialNumber);
1744             operationPayload.addProperty("locale", "");
1745             operationPayload.addProperty("customerId", getCustomerId(device.deviceOwnerCustomerId));
1746         }
1747         for (String key : parameters.keySet()) {
1748             Object value = parameters.get(key);
1749             if (value instanceof String) {
1750                 operationPayload.addProperty(key, (String) value);
1751             } else if (value instanceof Number) {
1752                 operationPayload.addProperty(key, (Number) value);
1753             } else if (value instanceof Boolean) {
1754                 operationPayload.addProperty(key, (Boolean) value);
1755             } else if (value instanceof Character) {
1756                 operationPayload.addProperty(key, (Character) value);
1757             } else {
1758                 operationPayload.add(key, gson.toJsonTree(value));
1759             }
1760         }
1761
1762         JsonObject nodeToExecute = new JsonObject();
1763         nodeToExecute.addProperty("@type", "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode");
1764         nodeToExecute.addProperty("type", command);
1765         if ("Alexa.TextCommand".equals(command)) {
1766             nodeToExecute.addProperty("skillId", "amzn1.ask.1p.tellalexa");
1767         }
1768         nodeToExecute.add("operationPayload", operationPayload);
1769         return nodeToExecute;
1770     }
1771
1772     @Nullable
1773     private ExecutionNodeObject getExecutionNodeObject(JsonObject nodeToExecute) {
1774         ExecutionNodeObject executionNodeObject = new ExecutionNodeObject();
1775         if (nodeToExecute.has("nodesToExecute")) {
1776             JsonArray serialNodesToExecute = nodeToExecute.getAsJsonArray("nodesToExecute");
1777             if (serialNodesToExecute != null && serialNodesToExecute.size() > 0) {
1778                 for (int i = 0; i < serialNodesToExecute.size(); i++) {
1779                     JsonObject serialNodesToExecuteJsonObject = serialNodesToExecute.get(i).getAsJsonObject();
1780                     if (serialNodesToExecuteJsonObject.has("nodesToExecute")) {
1781                         JsonArray parallelNodesToExecute = serialNodesToExecuteJsonObject
1782                                 .getAsJsonArray("nodesToExecute");
1783                         if (parallelNodesToExecute != null && parallelNodesToExecute.size() > 0) {
1784                             JsonObject parallelNodesToExecuteJsonObject = parallelNodesToExecute.get(0)
1785                                     .getAsJsonObject();
1786                             if (processNodesToExecuteJsonObject(executionNodeObject,
1787                                     parallelNodesToExecuteJsonObject)) {
1788                                 break;
1789                             }
1790                         }
1791                     } else {
1792                         if (processNodesToExecuteJsonObject(executionNodeObject, serialNodesToExecuteJsonObject)) {
1793                             break;
1794                         }
1795                     }
1796                 }
1797             }
1798         }
1799
1800         return executionNodeObject;
1801     }
1802
1803     private boolean processNodesToExecuteJsonObject(ExecutionNodeObject executionNodeObject,
1804             JsonObject nodesToExecuteJsonObject) {
1805         if (nodesToExecuteJsonObject.has("type")) {
1806             executionNodeObject.types.add(nodesToExecuteJsonObject.get("type").getAsString());
1807             if (nodesToExecuteJsonObject.has("operationPayload")) {
1808                 JsonObject operationPayload = nodesToExecuteJsonObject.getAsJsonObject("operationPayload");
1809                 if (operationPayload != null) {
1810                     if (operationPayload.has("textToSpeak")) {
1811                         executionNodeObject.text = operationPayload.get("textToSpeak").getAsString();
1812                         return true;
1813                     } else if (operationPayload.has("text")) {
1814                         executionNodeObject.text = operationPayload.get("text").getAsString();
1815                         return true;
1816                     } else if (operationPayload.has("content")) {
1817                         JsonArray content = operationPayload.getAsJsonArray("content");
1818                         if (content != null && content.size() > 0) {
1819                             JsonObject contentJsonObject = content.get(0).getAsJsonObject();
1820                             if (contentJsonObject.has("speak")) {
1821                                 JsonObject speak = contentJsonObject.getAsJsonObject("speak");
1822                                 if (speak != null && speak.has("value")) {
1823                                     executionNodeObject.text = speak.get("value").getAsString();
1824                                     return true;
1825                                 }
1826                             }
1827                         }
1828                     }
1829                 }
1830             }
1831         }
1832         return false;
1833     }
1834
1835     public void startRoutine(Device device, String utterance)
1836             throws IOException, URISyntaxException, InterruptedException {
1837         JsonAutomation found = null;
1838         String deviceLocale = "";
1839         JsonAutomation[] routines = getRoutines();
1840         if (routines == null) {
1841             return;
1842         }
1843         for (JsonAutomation routine : routines) {
1844             if (routine != null) {
1845                 Trigger[] triggers = routine.triggers;
1846                 if (triggers != null && routine.sequence != null) {
1847                     for (JsonAutomation.Trigger trigger : triggers) {
1848                         if (trigger == null) {
1849                             continue;
1850                         }
1851                         Payload payload = trigger.payload;
1852                         if (payload == null) {
1853                             continue;
1854                         }
1855                         String payloadUtterance = payload.utterance;
1856                         if (payloadUtterance != null && payloadUtterance.equalsIgnoreCase(utterance)) {
1857                             found = routine;
1858                             deviceLocale = payload.locale;
1859                             break;
1860                         }
1861                     }
1862                 }
1863             }
1864         }
1865         if (found != null) {
1866             String sequenceJson = gson.toJson(found.sequence);
1867
1868             JsonStartRoutineRequest request = new JsonStartRoutineRequest();
1869             request.behaviorId = found.automationId;
1870
1871             // replace tokens
1872             // "deviceType":"ALEXA_CURRENT_DEVICE_TYPE"
1873             String deviceType = "\"deviceType\":\"ALEXA_CURRENT_DEVICE_TYPE\"";
1874             String newDeviceType = "\"deviceType\":\"" + device.deviceType + "\"";
1875             sequenceJson = sequenceJson.replace(deviceType.subSequence(0, deviceType.length()),
1876                     newDeviceType.subSequence(0, newDeviceType.length()));
1877
1878             // "deviceSerialNumber":"ALEXA_CURRENT_DSN"
1879             String deviceSerial = "\"deviceSerialNumber\":\"ALEXA_CURRENT_DSN\"";
1880             String newDeviceSerial = "\"deviceSerialNumber\":\"" + device.serialNumber + "\"";
1881             sequenceJson = sequenceJson.replace(deviceSerial.subSequence(0, deviceSerial.length()),
1882                     newDeviceSerial.subSequence(0, newDeviceSerial.length()));
1883
1884             // "customerId": "ALEXA_CUSTOMER_ID"
1885             String customerId = "\"customerId\":\"ALEXA_CUSTOMER_ID\"";
1886             String newCustomerId = "\"customerId\":\"" + getCustomerId(device.deviceOwnerCustomerId) + "\"";
1887             sequenceJson = sequenceJson.replace(customerId.subSequence(0, customerId.length()),
1888                     newCustomerId.subSequence(0, newCustomerId.length()));
1889
1890             // "locale": "ALEXA_CURRENT_LOCALE"
1891             String locale = "\"locale\":\"ALEXA_CURRENT_LOCALE\"";
1892             String newlocale = deviceLocale != null && !deviceLocale.isEmpty() ? "\"locale\":\"" + deviceLocale + "\""
1893                     : "\"locale\":null";
1894             sequenceJson = sequenceJson.replace(locale.subSequence(0, locale.length()),
1895                     newlocale.subSequence(0, newlocale.length()));
1896
1897             request.sequenceJson = sequenceJson;
1898
1899             String requestJson = gson.toJson(request);
1900             makeRequest("POST", alexaServer + "/api/behaviors/preview", requestJson, true, true, null, 3);
1901         } else {
1902             logger.warn("Routine {} not found", utterance);
1903         }
1904     }
1905
1906     public @Nullable JsonAutomation @Nullable [] getRoutines()
1907             throws IOException, URISyntaxException, InterruptedException {
1908         String json = makeRequestAndReturnString(alexaServer + "/api/behaviors/automations?limit=2000");
1909         JsonAutomation[] result = parseJson(json, JsonAutomation[].class);
1910         return result;
1911     }
1912
1913     public JsonFeed[] getEnabledFlashBriefings() throws IOException, URISyntaxException, InterruptedException {
1914         String json = makeRequestAndReturnString(alexaServer + "/api/content-skills/enabled-feeds");
1915         JsonEnabledFeeds result = parseJson(json, JsonEnabledFeeds.class);
1916         if (result == null) {
1917             return new JsonFeed[0];
1918         }
1919         JsonFeed[] enabledFeeds = result.enabledFeeds;
1920         if (enabledFeeds != null) {
1921             return enabledFeeds;
1922         }
1923         return new JsonFeed[0];
1924     }
1925
1926     public void setEnabledFlashBriefings(JsonFeed[] enabledFlashBriefing)
1927             throws IOException, URISyntaxException, InterruptedException {
1928         JsonEnabledFeeds enabled = new JsonEnabledFeeds();
1929         enabled.enabledFeeds = enabledFlashBriefing;
1930         String json = gsonWithNullSerialization.toJson(enabled);
1931         makeRequest("POST", alexaServer + "/api/content-skills/enabled-feeds", json, true, true, null, 0);
1932     }
1933
1934     public JsonNotificationSound[] getNotificationSounds(Device device)
1935             throws IOException, URISyntaxException, InterruptedException {
1936         String json = makeRequestAndReturnString(
1937                 alexaServer + "/api/notification/sounds?deviceSerialNumber=" + device.serialNumber + "&deviceType="
1938                         + device.deviceType + "&softwareVersion=" + device.softwareVersion);
1939         JsonNotificationSounds result = parseJson(json, JsonNotificationSounds.class);
1940         if (result == null) {
1941             return new JsonNotificationSound[0];
1942         }
1943         JsonNotificationSound[] notificationSounds = result.notificationSounds;
1944         if (notificationSounds != null) {
1945             return notificationSounds;
1946         }
1947         return new JsonNotificationSound[0];
1948     }
1949
1950     public JsonNotificationResponse[] notifications() throws IOException, URISyntaxException, InterruptedException {
1951         String response = makeRequestAndReturnString(alexaServer + "/api/notifications");
1952         JsonNotificationsResponse result = parseJson(response, JsonNotificationsResponse.class);
1953         if (result == null) {
1954             return new JsonNotificationResponse[0];
1955         }
1956         JsonNotificationResponse[] notifications = result.notifications;
1957         if (notifications == null) {
1958             return new JsonNotificationResponse[0];
1959         }
1960         return notifications;
1961     }
1962
1963     public @Nullable JsonNotificationResponse notification(Device device, String type, @Nullable String label,
1964             @Nullable JsonNotificationSound sound) throws IOException, URISyntaxException, InterruptedException {
1965         Date date = new Date(new Date().getTime());
1966         long createdDate = date.getTime();
1967         Date alarm = new Date(createdDate + 5000); // add 5 seconds, because amazon does not except calls for times in
1968         // the past (compared with the server time)
1969         long alarmTime = alarm.getTime();
1970
1971         JsonNotificationRequest request = new JsonNotificationRequest();
1972         request.type = type;
1973         request.deviceSerialNumber = device.serialNumber;
1974         request.deviceType = device.deviceType;
1975         request.createdDate = createdDate;
1976         request.alarmTime = alarmTime;
1977         request.reminderLabel = label;
1978         request.sound = sound;
1979         request.originalDate = new SimpleDateFormat("yyyy-MM-dd").format(alarm);
1980         request.originalTime = new SimpleDateFormat("HH:mm:ss.SSSS").format(alarm);
1981         request.type = type;
1982         request.id = "create" + type;
1983
1984         String data = gsonWithNullSerialization.toJson(request);
1985         String response = makeRequestAndReturnString("PUT", alexaServer + "/api/notifications/createReminder", data,
1986                 true, null);
1987         JsonNotificationResponse result = parseJson(response, JsonNotificationResponse.class);
1988         return result;
1989     }
1990
1991     public void stopNotification(JsonNotificationResponse notification)
1992             throws IOException, URISyntaxException, InterruptedException {
1993         makeRequestAndReturnString("DELETE", alexaServer + "/api/notifications/" + notification.id, null, true, null);
1994     }
1995
1996     public @Nullable JsonNotificationResponse getNotificationState(JsonNotificationResponse notification)
1997             throws IOException, URISyntaxException, InterruptedException {
1998         String response = makeRequestAndReturnString("GET", alexaServer + "/api/notifications/" + notification.id, null,
1999                 true, null);
2000         JsonNotificationResponse result = parseJson(response, JsonNotificationResponse.class);
2001         return result;
2002     }
2003
2004     public List<JsonMusicProvider> getMusicProviders() {
2005         try {
2006             Map<String, String> headers = new HashMap<>();
2007             headers.put("Routines-Version", "1.1.218665");
2008             String response = makeRequestAndReturnString("GET",
2009                     alexaServer + "/api/behaviors/entities?skillId=amzn1.ask.1p.music", null, true, headers);
2010             if (!response.isEmpty()) {
2011                 JsonMusicProvider[] result = parseJson(response, JsonMusicProvider[].class);
2012                 return Arrays.asList(result);
2013             }
2014         } catch (IOException | URISyntaxException | InterruptedException e) {
2015             logger.warn("getMusicProviders fails: {}", e.getMessage());
2016         }
2017         return List.of();
2018     }
2019
2020     public void playMusicVoiceCommand(Device device, String providerId, String voiceCommand)
2021             throws IOException, URISyntaxException, InterruptedException {
2022         JsonPlaySearchPhraseOperationPayload payload = new JsonPlaySearchPhraseOperationPayload();
2023         payload.customerId = getCustomerId(device.deviceOwnerCustomerId);
2024         payload.locale = "ALEXA_CURRENT_LOCALE";
2025         payload.musicProviderId = providerId;
2026         payload.searchPhrase = voiceCommand;
2027
2028         String playloadString = gson.toJson(payload);
2029
2030         JsonObject postValidationJson = new JsonObject();
2031
2032         postValidationJson.addProperty("type", "Alexa.Music.PlaySearchPhrase");
2033         postValidationJson.addProperty("operationPayload", playloadString);
2034
2035         String postDataValidate = postValidationJson.toString();
2036
2037         String validateResultJson = makeRequestAndReturnString("POST",
2038                 alexaServer + "/api/behaviors/operation/validate", postDataValidate, true, null);
2039
2040         if (!validateResultJson.isEmpty()) {
2041             JsonPlayValidationResult validationResult = parseJson(validateResultJson, JsonPlayValidationResult.class);
2042             if (validationResult != null) {
2043                 JsonPlaySearchPhraseOperationPayload validatedOperationPayload = validationResult.operationPayload;
2044                 if (validatedOperationPayload != null) {
2045                     payload.sanitizedSearchPhrase = validatedOperationPayload.sanitizedSearchPhrase;
2046                     payload.searchPhrase = validatedOperationPayload.searchPhrase;
2047                 }
2048             }
2049         }
2050
2051         payload.locale = null;
2052         payload.deviceSerialNumber = device.serialNumber;
2053         payload.deviceType = device.deviceType;
2054
2055         JsonObject sequenceJson = new JsonObject();
2056         sequenceJson.addProperty("@type", "com.amazon.alexa.behaviors.model.Sequence");
2057         JsonObject startNodeJson = new JsonObject();
2058         startNodeJson.addProperty("@type", "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode");
2059         startNodeJson.addProperty("type", "Alexa.Music.PlaySearchPhrase");
2060         startNodeJson.add("operationPayload", gson.toJsonTree(payload));
2061         sequenceJson.add("startNode", startNodeJson);
2062
2063         JsonStartRoutineRequest startRoutineRequest = new JsonStartRoutineRequest();
2064         startRoutineRequest.sequenceJson = sequenceJson.toString();
2065         startRoutineRequest.status = null;
2066
2067         String postData = gson.toJson(startRoutineRequest);
2068         makeRequest("POST", alexaServer + "/api/behaviors/preview", postData, true, true, null, 3);
2069     }
2070
2071     public @Nullable JsonEqualizer getEqualizer(Device device)
2072             throws IOException, URISyntaxException, InterruptedException {
2073         String json = makeRequestAndReturnString(
2074                 alexaServer + "/api/equalizer/" + device.serialNumber + "/" + device.deviceType);
2075         return parseJson(json, JsonEqualizer.class);
2076     }
2077
2078     public void setEqualizer(Device device, JsonEqualizer settings)
2079             throws IOException, URISyntaxException, InterruptedException {
2080         String postData = gson.toJson(settings);
2081         makeRequest("POST", alexaServer + "/api/equalizer/" + device.serialNumber + "/" + device.deviceType, postData,
2082                 true, true, null, 0);
2083     }
2084
2085     public static class AnnouncementWrapper {
2086         public List<Device> devices = new ArrayList<>();
2087         public String speak;
2088         public String bodyText;
2089         public @Nullable String title;
2090         public List<@Nullable Integer> ttsVolumes = new ArrayList<>();
2091         public List<@Nullable Integer> standardVolumes = new ArrayList<>();
2092
2093         public AnnouncementWrapper(String speak, String bodyText, @Nullable String title) {
2094             this.speak = speak;
2095             this.bodyText = bodyText;
2096             this.title = title;
2097         }
2098     }
2099
2100     private static class TextToSpeech {
2101         public List<Device> devices = new ArrayList<>();
2102         public String text;
2103         public List<@Nullable Integer> ttsVolumes = new ArrayList<>();
2104         public List<@Nullable Integer> standardVolumes = new ArrayList<>();
2105
2106         public TextToSpeech(String text) {
2107             this.text = text;
2108         }
2109     }
2110
2111     private static class TextCommand {
2112         public List<Device> devices = new ArrayList<>();
2113         public String text;
2114         public List<@Nullable Integer> ttsVolumes = new ArrayList<>();
2115         public List<@Nullable Integer> standardVolumes = new ArrayList<>();
2116
2117         public TextCommand(String text) {
2118             this.text = text;
2119         }
2120     }
2121
2122     private static class Volume {
2123         public List<Device> devices = new ArrayList<>();
2124         public int volume;
2125         public List<@Nullable Integer> volumes = new ArrayList<>();
2126
2127         public Volume(int volume) {
2128             this.volume = volume;
2129         }
2130     }
2131
2132     private static class QueueObject {
2133         public @Nullable Future<?> future;
2134         public List<Device> devices = List.of();
2135         public JsonObject nodeToExecute = new JsonObject();
2136     }
2137
2138     private static class ExecutionNodeObject {
2139         public List<String> types = new ArrayList<>();
2140         @Nullable
2141         public String text;
2142     }
2143 }