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