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