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