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