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