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