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