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