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