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