]> git.basschouten.com Git - openhab-addons.git/blob
b82cba06ef50c4bbeb2e6281d6195d29bc467f6b
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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.samsungtv.internal.protocol;
14
15 import static org.openhab.binding.samsungtv.internal.SamsungTvBindingConstants.*;
16
17 import java.io.BufferedOutputStream;
18 import java.io.ByteArrayInputStream;
19 import java.io.DataOutputStream;
20 import java.io.IOException;
21 import java.net.Socket;
22 import java.net.URLConnection;
23 import java.nio.ByteBuffer;
24 import java.nio.charset.StandardCharsets;
25 import java.security.KeyManagementException;
26 import java.security.NoSuchAlgorithmException;
27 import java.security.cert.X509Certificate;
28 import java.time.Instant;
29 import java.time.ZoneId;
30 import java.time.format.DateTimeFormatter;
31 import java.util.ArrayList;
32 import java.util.Arrays;
33 import java.util.Collections;
34 import java.util.HashMap;
35 import java.util.Map;
36 import java.util.Optional;
37 import java.util.concurrent.TimeUnit;
38
39 import javax.net.ssl.SSLContext;
40 import javax.net.ssl.SSLSocket;
41 import javax.net.ssl.SSLSocketFactory;
42 import javax.net.ssl.TrustManager;
43 import javax.net.ssl.X509TrustManager;
44
45 import org.eclipse.jdt.annotation.NonNullByDefault;
46 import org.eclipse.jdt.annotation.Nullable;
47 import org.openhab.core.library.types.DecimalType;
48 import org.openhab.core.library.types.OnOffType;
49 import org.openhab.core.library.types.PercentType;
50 import org.openhab.core.library.types.RawType;
51 import org.openhab.core.library.types.StringType;
52 import org.openhab.core.types.State;
53 import org.openhab.core.types.UnDefType;
54 import org.slf4j.Logger;
55 import org.slf4j.LoggerFactory;
56
57 import com.google.gson.JsonElement;
58 import com.google.gson.JsonSyntaxException;
59
60 /**
61  * Websocket class to retrieve artmode status (on o.a. the Frame TV's)
62  *
63  * @author Arjan Mels - Initial contribution
64  * @author Nick Waterton - added slideshow handling, upload/download, refactoring
65  */
66 @NonNullByDefault
67 class WebSocketArt extends WebSocketBase {
68     private final Logger logger = LoggerFactory.getLogger(WebSocketArt.class);
69
70     private String host = "";
71     private String className = "";
72     private String slideShowDuration = "off";
73     // Favourites is default
74     private String categoryId = "MY-C0004";
75     private String lastThumbnail = "";
76     private boolean slideshow = false;
77     public byte[] imageBytes = new byte[0];
78     public String fileType = "jpg";
79     private long connection_id_random = 2705890518L;
80     private static final DateTimeFormatter DATEFORMAT = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss")
81             .withZone(ZoneId.systemDefault());
82     private Map<String, String> stateMap = Collections.synchronizedMap(new HashMap<>());
83     private @Nullable SSLSocketFactory sslsocketfactory = null;
84
85     /**
86      * @param remoteControllerWebSocket
87      */
88     WebSocketArt(RemoteControllerWebSocket remoteControllerWebSocket) {
89         super(remoteControllerWebSocket);
90         this.host = remoteControllerWebSocket.host;
91         this.className = this.getClass().getSimpleName();
92
93         try {
94             SSLContext sslContext = SSLContext.getInstance("TLS");
95             sslContext.init(null, acceptAlltrustManagers(), null);
96             sslsocketfactory = sslContext.getSocketFactory();
97         } catch (KeyManagementException | NoSuchAlgorithmException e) {
98             logger.debug("{}: sslsocketfactory failed to initialize: {}", host, e.getMessage());
99         }
100     }
101
102     @NonNullByDefault({})
103     @SuppressWarnings("unused")
104     private class JSONMessage {
105         String event;
106
107         class Data {
108             String event;
109             String status;
110             String version;
111             String value;
112             String current_content_id;
113             String content_id;
114             String category_id;
115             int current_rotation_status;
116             String is_shown;
117             String type;
118             String file_type;
119             String conn_info;
120             String data;
121
122             public String getEvent() {
123                 return Optional.ofNullable(event).orElse("");
124             }
125
126             public String getStatus() {
127                 return Optional.ofNullable(status).orElse("");
128             }
129
130             public int getVersion() {
131                 return Optional.ofNullable(version).map(a -> a.replace(".", "")).map(Integer::parseInt).orElse(0);
132             }
133
134             public String getValue() {
135                 return Optional.ofNullable(value).orElse(getStatus());
136             }
137
138             public int getIntValue() {
139                 return Optional.of(Integer.valueOf(getValue())).orElse(0);
140             }
141
142             public String getCategoryId() {
143                 return Optional.ofNullable(category_id).orElse("");
144             }
145
146             public String getContentId() {
147                 return Optional.ofNullable(content_id).orElse(getCurrentContentId());
148             }
149
150             public String getCurrentContentId() {
151                 return Optional.ofNullable(current_content_id).orElse("");
152             }
153
154             public int getRotationStatus() {
155                 return Optional.ofNullable(Integer.valueOf(current_rotation_status)).orElse(0);
156             }
157
158             public String getType() {
159                 return Optional.ofNullable(type).orElse("");
160             }
161
162             public String getIsShown() {
163                 return Optional.ofNullable(is_shown).orElse("No");
164             }
165
166             public String getFileType() {
167                 return Optional.ofNullable(file_type).orElse("");
168             }
169
170             public String getConnInfo() {
171                 return Optional.ofNullable(conn_info).orElse("");
172             }
173
174             public String getData() {
175                 return Optional.ofNullable(data).orElse("");
176             }
177         }
178
179         class BinaryData {
180             byte[] data;
181             int off;
182             int len;
183
184             BinaryData(byte[] data, int off, int len) {
185                 this.data = data;
186                 this.off = off;
187                 this.len = len;
188             }
189
190             public byte[] getBinaryData() {
191                 return Optional.ofNullable(data).orElse(new byte[0]);
192             }
193
194             public int getOff() {
195                 return Optional.ofNullable(off).orElse(0);
196             }
197
198             public int getLen() {
199                 return Optional.ofNullable(len).orElse(0);
200             }
201         }
202
203         class ArtmodeSettings {
204             String item;
205             String value;
206             String min;
207             String max;
208             String valid_values;
209
210             public String getItem() {
211                 return Optional.ofNullable(item).orElse("");
212             }
213
214             public int getValue() {
215                 return Optional.ofNullable(value).map(a -> Integer.valueOf(a)).orElse(0);
216             }
217         }
218
219         class Conninfo {
220             String d2d_mode;
221             long connection_id;
222             String request_id;
223             String id;
224         }
225
226         class Contentinfo {
227             String contentInfo;
228             String event;
229             String ip;
230             String port;
231             String key;
232             String stat;
233             boolean secured;
234             String mode;
235
236             public String getContentInfo() {
237                 return Optional.ofNullable(contentInfo).orElse("");
238             }
239
240             public String getIp() {
241                 return Optional.ofNullable(ip).orElse("");
242             }
243
244             public int getPort() {
245                 return Optional.ofNullable(port).map(Integer::parseInt).orElse(0);
246             }
247
248             public String getKey() {
249                 return Optional.ofNullable(key).orElse("");
250             }
251
252             public boolean getSecured() {
253                 return Optional.ofNullable(secured).orElse(false);
254             }
255         }
256
257         class Header {
258             String connection_id;
259             String seckey;
260             String version;
261             String fileID;
262             String fileName;
263             String fileType;
264             String num;
265             String total;
266             String fileLength;
267
268             Header(int fileLength) {
269                 this.fileLength = String.valueOf(fileLength);
270             }
271
272             public int getFileLength() {
273                 return Optional.ofNullable(fileLength).map(Integer::parseInt).orElse(0);
274             }
275
276             public String getFileType() {
277                 return Optional.ofNullable(fileType).orElse("");
278             }
279
280             public String getFileID() {
281                 return Optional.ofNullable(fileID).orElse("");
282             }
283         }
284
285         // data is sometimes a json object, sometimes a string representation of a json object for d2d_service_message
286         @Nullable
287         JsonElement data;
288
289         BinaryData binData;
290
291         public String getEvent() {
292             return Optional.ofNullable(event).orElse("");
293         }
294
295         public String getData() {
296             return Optional.ofNullable(data).map(a -> a.getAsString()).orElse("");
297         }
298
299         public void putBinaryData(byte[] arr, int off, int len) {
300             this.binData = new BinaryData(arr, off, len);
301         }
302
303         public BinaryData getBinaryData() {
304             return Optional.ofNullable(binData).orElse(new BinaryData(new byte[0], 0, 0));
305         }
306     }
307
308     @Override
309     public void onWebSocketBinary(byte @Nullable [] arr, int off, int len) {
310         if (arr == null) {
311             return;
312         }
313         super.onWebSocketBinary(arr, off, len);
314         String msg = extractMsg(arr, off, len, true);
315         // offset is start of binary data
316         int offset = ByteBuffer.wrap(arr, off, len).getShort() + off + 2; // 2 = length of Short
317         try {
318             JSONMessage jsonMsg = remoteControllerWebSocket.gson.fromJson(msg, JSONMessage.class);
319             if (jsonMsg == null) {
320                 return;
321             }
322             switch (jsonMsg.getEvent()) {
323                 case "d2d_service_message":
324                     jsonMsg.putBinaryData(arr, offset, len);
325                     handleD2DServiceMessage(jsonMsg);
326                     break;
327                 default:
328                     logger.debug("{}: WebSocketArt(binary) Unknown event: {}", host, msg);
329             }
330         } catch (JsonSyntaxException e) {
331             logger.warn("{}: {}: Error ({}) in message: {}", host, className, e.getMessage(), msg);
332         }
333     }
334
335     @Override
336     public synchronized void onWebSocketText(@Nullable String msgarg) {
337         if (msgarg == null) {
338             return;
339         }
340         String msg = msgarg.replace('\n', ' ');
341         super.onWebSocketText(msg);
342         try {
343             JSONMessage jsonMsg = remoteControllerWebSocket.gson.fromJson(msg, JSONMessage.class);
344             if (jsonMsg == null) {
345                 return;
346             }
347             switch (jsonMsg.getEvent()) {
348                 case "ms.channel.connect":
349                     logger.debug("{}: Art channel connected", host);
350                     break;
351                 case "ms.channel.ready":
352                     logger.debug("{}: Art channel ready", host);
353                     stateMap.clear();
354                     if (remoteControllerWebSocket.callback.getArtMode2022()) {
355                         remoteControllerWebSocket.callback.setArtMode2022(false);
356                         remoteControllerWebSocket.callback.setArtModeSupported(true);
357                         logger.info("{}: Art Mode has been renabled on Frame TV's >= 2022", host);
358                     }
359                     getArtmodeStatus();
360                     getArtmodeStatus("get_api_version");
361                     getArtmodeStatus("api_version");
362                     getArtmodeStatus("get_slideshow_status");
363                     getArtmodeStatus("get_auto_rotation_status");
364                     getArtmodeStatus("get_current_artwork");
365                     getArtmodeStatus("get_color_temperature");
366                     getArtmodeStatus("get_current_rotation");
367                     break;
368                 case "ms.channel.clientConnect":
369                     logger.debug("{}: Another Art client has connected", host);
370                     break;
371                 case "ms.channel.clientDisconnect":
372                     logger.debug("{}: Other Art client has disconnected", host);
373                     break;
374
375                 case "d2d_service_message":
376                     handleD2DServiceMessage(jsonMsg);
377                     break;
378                 default:
379                     logger.debug("{}: WebSocketArt Unknown event: {}", host, msg);
380             }
381         } catch (JsonSyntaxException e) {
382             logger.warn("{}: {}: Error ({}) in message: {}", host, className, e.getMessage(), msg);
383         }
384     }
385
386     /**
387      * handle D2DServiceMessages
388      *
389      * @param jsonMsg JSONMessage
390      *
391      */
392     private synchronized void handleD2DServiceMessage(JSONMessage jsonMsg) {
393         String msg = jsonMsg.getData();
394         try {
395             JSONMessage.Data data = remoteControllerWebSocket.gson.fromJson(msg, JSONMessage.Data.class);
396             if (data == null) {
397                 logger.debug("{}: Empty d2d_service_message event", host);
398                 return;
399             }
400             // remove returns and white space for ART_JSON channel
401             valueReceived(ART_JSON, new StringType(msg.trim().replaceAll("\\n|\\\\n", "").replaceAll("\\s{2,}", " ")));
402             switch (data.getEvent()) {
403                 case "error":
404                     logger.debug("{}: ERROR event: {}", host, msg);
405                     break;
406                 case "api_version":
407                     // old (2021) version is "2.03", new (2022) version is "4.3.4.0"
408                     logger.debug("{}: {}: {}", host, data.getEvent(), data.getVersion());
409                     if (data.getVersion() >= 4000) {
410                         setArtApiVersion(1);
411                     }
412                     logger.debug("{}: API Version set to: {}", host, getArtApiVersion());
413                     break;
414                 case "send_image":
415                 case "image_deleted":
416                 case "set_artmode_status":
417                 case "get_content_list":
418                 case "recently_set_updated":
419                 case "preview_started":
420                 case "preview_stopped":
421                 case "favorite_changed":
422                 case "content_list":
423                     // do nothing
424                     break;
425                 case "get_artmode_settings":
426                     logger.debug("{}: {}: {}", host, data.getEvent(), data.getData());
427                     msg = data.getData();
428                     if (!msg.isBlank()) {
429                         JSONMessage.ArtmodeSettings[] artmodeSettings = remoteControllerWebSocket.gson.fromJson(msg,
430                                 JSONMessage.ArtmodeSettings[].class);
431                         if (artmodeSettings != null) {
432                             for (JSONMessage.ArtmodeSettings setting : artmodeSettings) {
433                                 // extract brightness and colour temperature here
434                                 if ("brightness".equals(setting.getItem())) {
435                                     valueReceived(ART_BRIGHTNESS, new PercentType(setting.getValue() * 10));
436                                 }
437                                 if ("color_temperature".equals(setting.getItem())) {
438                                     valueReceived(ART_COLOR_TEMPERATURE, new DecimalType(setting.getValue()));
439                                 }
440                             }
441                         }
442                     }
443                     break;
444                 case "current_rotation_status":
445                 case "get_current_rotation":
446                     // Landscape = 1, Portrait = 2
447                     valueReceived(ART_ORIENTATION, OnOffType.from(data.getRotationStatus() == 2));
448                     break;
449                 case "set_brightness":
450                 case "brightness_changed":
451                 case "brightness":
452                     valueReceived(ART_BRIGHTNESS, new PercentType(data.getIntValue() * 10));
453                     break;
454                 case "set_color_temperature":
455                 case "color_temperature_changed":
456                 case "color_temperature":
457                     valueReceived(ART_COLOR_TEMPERATURE, new DecimalType(data.getIntValue()));
458                     break;
459                 case "get_artmode_status":
460                 case "art_mode_changed":
461                 case "artmode_status":
462                     logger.debug("{}: {}: {}", host, data.getEvent(), data.getValue());
463                     if ("off".equals(data.getValue())) {
464                         remoteControllerWebSocket.callback.powerUpdated(true, false);
465                         remoteControllerWebSocket.callback.currentAppUpdated("");
466                     } else {
467                         remoteControllerWebSocket.callback.powerUpdated(false, true);
468                     }
469                     if (!remoteControllerWebSocket.noApps()) {
470                         remoteControllerWebSocket.updateCurrentApp();
471                     }
472                     break;
473                 case "slideshow_image_changed":
474                 case "slideshow_changed":
475                 case "get_slideshow_status":
476                 case "auto_rotation_changed":
477                 case "auto_rotation_image_changed":
478                 case "auto_rotation_status":
479                     // value (duration) is "off" or "number" where number is duration in minutes
480                     // data.type: "shuffleslideshow" or "slideshow"
481                     // data.current_content_id: Current art displayed eg "MY_F0005"
482                     // data.category_id: category eg 'MY-C0004' ie favouries or my Photos/shelf
483                     if (!data.getValue().isBlank()) {
484                         slideShowDuration = data.getValue();
485                         slideshow = !"off".equals(data.getValue());
486                     }
487                     categoryId = (data.getCategoryId().isBlank()) ? categoryId : data.getCategoryId();
488                     if (!data.getContentId().isBlank() && slideshow) {
489                         remoteControllerWebSocket.callback.currentAppUpdated(
490                                 String.format("%s %s %s", data.getType(), slideShowDuration, categoryId));
491                     }
492                     logger.trace("{}: slideshow: {}, {}, {}, {}", host, data.getEvent(), data.getType(),
493                             data.getValue(), data.getContentId());
494                     break;
495                 case "image_added":
496                     if (!data.getCategoryId().isBlank()) {
497                         logger.debug("{}: Image added: {}, category: {}", host, data.getContentId(),
498                                 data.getCategoryId());
499                     }
500                     break;
501                 case "get_current_artwork":
502                 case "select_image":
503                 case "current_artwork":
504                 case "image_selected":
505                     // data.content_id: Current art displayed eg "MY_F0005"
506                     // data.is_shown: "Yes" or "No"
507                     if ("Yes".equals(data.getIsShown())) {
508                         if (!slideshow) {
509                             remoteControllerWebSocket.callback.currentAppUpdated("artMode");
510                         }
511                     }
512                     valueReceived(ART_LABEL, new StringType(data.getContentId()));
513                     if (remoteControllerWebSocket.callback.handler.isChannelLinked(ART_IMAGE)) {
514                         if (data.getEvent().contains("current_artwork") || "Yes".equals(data.getIsShown())) {
515                             getThumbnail(data.getContentId());
516                         }
517                     }
518                     break;
519                 case "get_thumbnail_list":
520                 case "thumbnail":
521                     logger.trace("{}: thumbnail: Fetching {}", host, data.getContentId());
522                 case "ready_to_use":
523                     // upload image (should be 3840x2160 pixels in size)
524                     msg = data.getConnInfo();
525                     if (!msg.isBlank()) {
526                         JSONMessage.Contentinfo contentInfo = remoteControllerWebSocket.gson.fromJson(msg,
527                                 JSONMessage.Contentinfo.class);
528                         if (contentInfo != null) {
529                             // NOTE: do not tie up the websocket receive loop for too long, so use the scheduler
530                             // upload image, or download thumbnail
531                             scheduleSocketOperation(contentInfo, "ready_to_use".equals(data.getEvent()));
532                         }
533                     } else {
534                         // <2019 (ish) Frame TV's return thumbnails as binary data
535                         receiveThumbnail(jsonMsg.getBinaryData());
536                     }
537                     break;
538                 case "go_to_standby":
539                     logger.debug("{}: go_to_standby", host);
540                     remoteControllerWebSocket.callback.powerUpdated(false, false);
541                     remoteControllerWebSocket.callback.setOffline();
542                     stateMap.clear();
543                     break;
544                 case "wakeup":
545                     stateMap.clear();
546                     logger.debug("{}: wakeup from standby", host);
547                     // check artmode status to know complete status before updating
548                     getArtmodeStatus();
549                     getArtmodeStatus((getArtApiVersion() == 0) ? "get_auto_rotation_status" : "get_slideshow_status");
550                     getArtmodeStatus("get_current_artwork");
551                     getArtmodeStatus("get_color_temperature");
552                     break;
553                 default:
554                     logger.debug("{}: Unknown d2d_service_message event: {}", host, msg);
555             }
556         } catch (JsonSyntaxException e) {
557             logger.warn("{}: {}: Error ({}) in message: {}", host, className, e.getMessage(), msg);
558         }
559     }
560
561     public void valueReceived(String variable, State value) {
562         if (!stateMap.getOrDefault(variable, "").equals(value.toString())) {
563             remoteControllerWebSocket.callback.handler.valueReceived(variable, value);
564             stateMap.put(variable, value.toString());
565         } else {
566             logger.trace("{}: Value '{}' for {} hasn't changed, ignoring update", host, value, variable);
567         }
568     }
569
570     public int getArtApiVersion() {
571         return remoteControllerWebSocket.callback.handler.artApiVersion;
572     }
573
574     public void setArtApiVersion(int apiVersion) {
575         remoteControllerWebSocket.callback.handler.artApiVersion = apiVersion;
576     }
577
578     /**
579      * creates formatted json string for art websocket commands
580      *
581      * @param request Array of string requests to format
582      *
583      */
584     @NonNullByDefault({})
585     class JSONArtModeStatus {
586         public JSONArtModeStatus(String[] request) {
587             Params.Data data = params.new Data();
588             if (request.length == 1) {
589                 if (request[0].endsWith("}")) {
590                     // full json request/command
591                     request = request[0].split(",");
592                 } else {
593                     // send simple command in request[0]
594                     data.request = request[0];
595                     params.data = remoteControllerWebSocket.gson.toJson(data);
596                     return;
597                 }
598             }
599             switch (request[0]) {
600                 // predefined requests/commands
601                 case "set_slideshow_status":
602                 case "set_auto_rotation_status":
603                     data.request = request[0];
604                     data.type = request[1];
605                     data.value = request[2];
606                     data.category_id = request[3];
607                     params.data = remoteControllerWebSocket.gson.toJson(data);
608                     break;
609                 case "set_brightness":
610                 case "set_color_temperature":
611                     data.request = request[0];
612                     data.value = request[1];
613                     params.data = remoteControllerWebSocket.gson.toJson(data);
614                     break;
615                 case "get_thumbnail":
616                     data.request = request[0];
617                     data.content_id = request[1];
618                     data.conn_info = new Conninfo();
619                     params.data = remoteControllerWebSocket.gson.toJson(data);
620                     break;
621                 case "get_thumbnail_list":
622                     connection_id_random++;
623                     data.request = request[0];
624                     Content_id_list content_id = new Content_id_list();
625                     content_id.content_id = request[1];
626                     data.content_id_list = new Content_id_list[] { content_id };
627                     data.conn_info = new Conninfo();
628                     params.data = remoteControllerWebSocket.gson.toJson(data);
629                     break;
630                 case "select_image":
631                     data.request = request[0];
632                     data.content_id = request[1];
633                     data.show = true;
634                     params.data = remoteControllerWebSocket.gson.toJson(data);
635                     break;
636                 case "send_image":
637                     RawType image = RawType.valueOf(request[1]);
638                     fileType = image.getMimeType().split("/")[1];
639                     imageBytes = image.getBytes();
640                     data.request = request[0];
641                     data.request_id = remoteControllerWebSocket.uuid.toString();
642                     data.file_type = fileType;
643                     data.conn_info = new Conninfo();
644                     data.image_date = DATEFORMAT.format(Instant.now());
645                     // data.matte_id = "flexible_polar";
646                     // data.portrait_matte_id = "flexible_polar";
647                     data.file_size = Long.valueOf(imageBytes.length);
648                     params.data = remoteControllerWebSocket.gson.toJson(data);
649                     break;
650                 default:
651                     // Just return formatted json (add id if needed)
652                     if (Arrays.stream(request).anyMatch(a -> a.contains("\"id\""))) {
653                         params.data = String.join(",", request).replace(",}", "}");
654                     } else {
655                         ArrayList<String> requestList = new ArrayList<>(Arrays.asList(request));
656                         requestList.add(requestList.size() - 1,
657                                 String.format("\"id\":\"%s\"", remoteControllerWebSocket.uuid.toString()));
658                         params.data = String.join(",", requestList).replace(",}", "}");
659                     }
660                     break;
661             }
662         }
663
664         class Params {
665             class Data {
666                 String request = "get_artmode_status";
667                 String value;
668                 String content_id;
669                 @Nullable
670                 Content_id_list[] content_id_list = null;
671                 String category_id;
672                 String type;
673                 String file_type;
674                 String image_date;
675                 String matte_id;
676                 Long file_size;
677                 @Nullable
678                 Boolean show = null;
679                 String request_id;
680                 String id = remoteControllerWebSocket.uuid.toString();
681                 Conninfo conn_info;
682             }
683
684             String event = "art_app_request";
685             String to = "host";
686             String data;
687         }
688
689         class Conninfo {
690             String d2d_mode = "socket";
691             // this is a random number usually
692             // long connection_id = 2705890518L;
693             long connection_id = connection_id_random;
694             String request_id;
695             String id = remoteControllerWebSocket.uuid.toString();
696         }
697
698         class Content_id_list {
699             String content_id;
700         }
701
702         String method = "ms.channel.emit";
703         Params params = new Params();
704     }
705
706     public void getThumbnail(String content_id) {
707         if (!content_id.equals(lastThumbnail) || "NULL".equals(stateMap.getOrDefault(ART_IMAGE, "NULL"))) {
708             getArtmodeStatus((getArtApiVersion() == 0) ? "get_thumbnail" : "get_thumbnail_list", content_id);
709             lastThumbnail = content_id;
710         } else {
711             logger.trace("{}: NOT getting thumbnail for: {} as it hasn't changed", host, content_id);
712         }
713     }
714
715     /**
716      * Extract header message from binary data
717      * <2019 (ish) Frame TV's return some messages as binary data
718      * First two bytes are a short giving the header length
719      * header is a D2DServiceMessages followed by the binary image data.
720      *
721      * Also Extract header information from image downloaded via socket
722      * in which case first four bytes are the header length followed by the binary image data.
723      *
724      * @param byte[] payload
725      * @param int offset (usually 0)
726      * @param int len
727      * @param boolean fromBinMsg true if this was received as a binary message (header length is a Short)
728      *
729      */
730     public String extractMsg(byte[] payload, int offset, int len, boolean fromBinMsg) {
731         ByteBuffer buf = ByteBuffer.wrap(payload, offset, len);
732         int headerlen = fromBinMsg ? buf.getShort() : buf.getInt();
733         offset += fromBinMsg ? Short.BYTES : Integer.BYTES;
734         String type = fromBinMsg ? "D2DServiceMessages(from binary)" : "image header";
735         String header = new String(payload, offset, headerlen, StandardCharsets.UTF_8);
736         logger.trace("{}: Got {}: {}", host, type, header);
737         return header;
738     }
739
740     /**
741      * Receive thumbnail from binary data returned by TV in response to get_thumbnail command
742      * <2019 (ish) Frame TV's return thumbnails as binary data.
743      *
744      * @param JSONMessage.BinaryData
745      *
746      */
747     public void receiveThumbnail(JSONMessage.BinaryData binaryData) {
748         extractThumbnail(binaryData.getBinaryData(), binaryData.getLen() - binaryData.getOff());
749     }
750
751     /**
752      * Return a no-op SSL trust manager which will not verify server or client certificates.
753      */
754     private TrustManager[] acceptAlltrustManagers() {
755         return new TrustManager[] { new X509TrustManager() {
756             @Override
757             public void checkClientTrusted(final X509Certificate @Nullable [] chain, final @Nullable String authType) {
758             }
759
760             @Override
761             public void checkServerTrusted(final X509Certificate @Nullable [] chain, final @Nullable String authType) {
762             }
763
764             @Override
765             public X509Certificate @Nullable [] getAcceptedIssuers() {
766                 return null;
767             }
768         } };
769     }
770
771     public void scheduleSocketOperation(JSONMessage.Contentinfo contentInfo, boolean upload) {
772         logger.trace("{}: scheduled scheduleSocketOperation()", host);
773         remoteControllerWebSocket.callback.handler.getScheduler().schedule(() -> {
774             if (upload) {
775                 uploadImage(contentInfo);
776             } else {
777                 downloadThumbnail(contentInfo);
778             }
779         }, 50, TimeUnit.MILLISECONDS);
780     }
781
782     /**
783      * Download thumbnail of current selected image/jpeg from ip+port
784      *
785      * @param contentinfo Contentinfo containing ip address and port to download from
786      *
787      */
788     public void downloadThumbnail(JSONMessage.Contentinfo contentInfo) {
789         logger.trace("{}: thumbnail: downloading from: ip:{}, port:{}, secured:{}", host, contentInfo.getIp(),
790                 contentInfo.getPort(), contentInfo.getSecured());
791         try {
792             Socket socket;
793             if (contentInfo.getSecured()) {
794                 if (sslsocketfactory != null) {
795                     logger.trace("{}: thumbnail SSL socket connecting", host);
796                     socket = sslsocketfactory.createSocket(contentInfo.getIp(), contentInfo.getPort());
797                 } else {
798                     logger.debug("{}: sslsocketfactory is null", host);
799                     return;
800                 }
801             } else {
802                 socket = new Socket(contentInfo.getIp(), contentInfo.getPort());
803             }
804             if (socket != null) {
805                 logger.trace("{}: thumbnail socket connected", host);
806                 byte[] payload = Optional.ofNullable(socket.getInputStream().readAllBytes()).orElse(new byte[0]);
807                 socket.close();
808                 if (payload.length > 0) {
809                     String header = extractMsg(payload, 0, payload.length, false);
810                     JSONMessage.Header headerData = Optional
811                             .ofNullable(remoteControllerWebSocket.gson.fromJson(header, JSONMessage.Header.class))
812                             .orElse(new JSONMessage().new Header(0));
813                     extractThumbnail(payload, headerData.getFileLength());
814                 } else {
815                     logger.trace("{}: thumbnail no data received", host);
816                     valueReceived(ART_IMAGE, UnDefType.NULL);
817                 }
818             }
819         } catch (IOException e) {
820             logger.warn("{}: Error downloading thumbnail {}", host, e.getMessage());
821         }
822     }
823
824     /**
825      * Extract thumbnail from payload
826      *
827      * @param payload byte[] containing binary data and possibly header info
828      * @param fileLength int with image file size
829      *
830      */
831     public void extractThumbnail(byte[] payload, int fileLength) {
832         try {
833             byte[] image = new byte[fileLength];
834             ByteBuffer.wrap(image).put(payload, payload.length - fileLength, fileLength);
835             String ftype = Optional
836                     .ofNullable(URLConnection.guessContentTypeFromStream(new ByteArrayInputStream(image)))
837                     .orElseThrow(() -> new Exception("Unable to determine image type"));
838             valueReceived(ART_IMAGE, new RawType(image, ftype));
839         } catch (Exception e) {
840             if (logger.isTraceEnabled()) {
841                 logger.trace("{}: Error extracting thumbnail: ", host, e);
842             } else {
843                 logger.warn("{}: Error extracting thumbnail {}", host, e.getMessage());
844             }
845         }
846     }
847
848     @NonNullByDefault({})
849     @SuppressWarnings("unused")
850     private class JSONHeader {
851         public JSONHeader(int num, int total, long fileLength, String fileType, String secKey) {
852             this.num = num;
853             this.total = total;
854             this.fileLength = fileLength;
855             this.fileType = fileType;
856             this.secKey = secKey;
857         }
858
859         int num = 0;
860         int total = 1;
861         long fileLength;
862         String fileName = "dummy";
863         String fileType;
864         String secKey;
865         String version = "0.0.1";
866     }
867
868     /**
869      * Upload Image from ART_IMAGE/ART_LABEL channel
870      *
871      * @param contentinfo Contentinfo containing ip address, port and key to upload to
872      *
873      *            imageBytes and fileType are class instance variables obtained from the
874      *            getArtmodeStatus() command that triggered the upload.
875      *
876      */
877     public void uploadImage(JSONMessage.Contentinfo contentInfo) {
878         logger.trace("{}: Uploading image to ip:{}, port:{}", host, contentInfo.getIp(), contentInfo.getPort());
879         try {
880             Socket socket;
881             if (contentInfo.getSecured()) {
882                 if (sslsocketfactory != null) {
883                     logger.trace("{}: upload SSL socket connecting", host);
884                     socket = (SSLSocket) sslsocketfactory.createSocket(contentInfo.getIp(), contentInfo.getPort());
885                 } else {
886                     logger.debug("{}: sslsocketfactory is null", host);
887                     return;
888                 }
889             } else {
890                 socket = new Socket(contentInfo.getIp(), contentInfo.getPort());
891             }
892             if (socket != null) {
893                 logger.trace("{}: upload socket connected", host);
894                 DataOutputStream dataOutputStream = new DataOutputStream(
895                         new BufferedOutputStream(socket.getOutputStream()));
896                 String header = remoteControllerWebSocket.gson
897                         .toJson(new JSONHeader(0, 1, imageBytes.length, fileType, contentInfo.getKey()));
898                 logger.debug("{}: Image header: {}, {} bytes", host, header, header.length());
899                 dataOutputStream.writeInt(header.length());
900                 dataOutputStream.writeBytes(header);
901                 dataOutputStream.write(imageBytes, 0, imageBytes.length);
902                 dataOutputStream.flush();
903                 logger.debug("{}: wrote Image:{} {} bytes to TV", host, fileType, dataOutputStream.size());
904                 socket.close();
905             }
906         } catch (IOException e) {
907             logger.warn("{}: Error writing image to TV {}", host, e.getMessage());
908         }
909     }
910
911     /**
912      * Set slideshow
913      *
914      * @param command split on ,space or + where
915      *
916      *            First parameter is shuffleslideshow or slideshow
917      *            Second is duration in minutes or off
918      *            Third is category where the value is somethng like MY-C0004 = Favourites or MY-C0002 = My Photos.
919      *
920      */
921     public void setSlideshow(String command) {
922         String[] cmd = command.split("[, +]");
923         if (cmd.length <= 1) {
924             logger.warn("{}: Invalid slideshow command: {}", host, command);
925             return;
926         }
927         String value = ("0".equals(cmd[1])) ? "off" : cmd[1];
928         categoryId = (cmd.length >= 3) ? cmd[2] : categoryId;
929         getArtmodeStatus((getArtApiVersion() == 0) ? "set_auto_rotation_status" : "set_slideshow_status",
930                 cmd[0].toLowerCase(), value, categoryId);
931     }
932
933     /**
934      * Send commands to Frame TV Art websocket channel
935      *
936      * @param optionalRequests Array of string requests
937      *
938      */
939     void getArtmodeStatus(String... optionalRequests) {
940         if (optionalRequests.length == 0) {
941             optionalRequests = new String[] { "get_artmode_status" };
942         }
943         if (getArtApiVersion() != 0) {
944             if ("get_brightness".equals(optionalRequests[0])) {
945                 optionalRequests = new String[] { "get_artmode_settings" };
946             }
947             if ("get_color_temperature".equals(optionalRequests[0])) {
948                 optionalRequests = new String[] { "get_artmode_settings" };
949             }
950         }
951         sendCommand(remoteControllerWebSocket.gson.toJson(new JSONArtModeStatus(optionalRequests)));
952     }
953 }