]> git.basschouten.com Git - openhab-addons.git/blob
3fbe15e3e6bdc290c5ac26008af9d97b736631b4
[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.config.SamsungTvConfiguration.*;
16
17 import java.net.URI;
18 import java.net.URISyntaxException;
19 import java.time.Instant;
20 import java.util.List;
21 import java.util.Map;
22 import java.util.Optional;
23 import java.util.UUID;
24 import java.util.concurrent.ConcurrentHashMap;
25 import java.util.concurrent.ScheduledExecutorService;
26 import java.util.concurrent.TimeUnit;
27 import java.util.stream.Collectors;
28 import java.util.stream.Stream;
29
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.util.component.LifeCycle;
33 import org.eclipse.jetty.util.component.LifeCycle.Listener;
34 import org.eclipse.jetty.util.ssl.SslContextFactory;
35 import org.eclipse.jetty.websocket.client.WebSocketClient;
36 import org.openhab.binding.samsungtv.internal.SamsungTvAppWatchService;
37 import org.openhab.binding.samsungtv.internal.Utils;
38 import org.openhab.binding.samsungtv.internal.service.RemoteControllerService;
39 import org.openhab.core.io.net.http.WebSocketFactory;
40 import org.slf4j.Logger;
41 import org.slf4j.LoggerFactory;
42
43 import com.google.gson.Gson;
44 import com.google.gson.JsonSyntaxException;
45
46 /**
47  * The {@link RemoteControllerWebSocket} is responsible for sending key codes to the
48  * Samsung TV via the websocket protocol (for newer TV's).
49  *
50  * @author Arjan Mels - Initial contribution
51  * @author Arjan Mels - Moved websocket inner classes to standalone classes
52  * @author Nick Waterton - added Action enum, manual app handling and some refactoring
53  */
54 @NonNullByDefault
55 public class RemoteControllerWebSocket extends RemoteController implements Listener {
56
57     private final Logger logger = LoggerFactory.getLogger(RemoteControllerWebSocket.class);
58
59     private static final String WS_ENDPOINT_REMOTE_CONTROL = "/api/v2/channels/samsung.remote.control";
60     private static final String WS_ENDPOINT_ART = "/api/v2/channels/com.samsung.art-app";
61     private static final String WS_ENDPOINT_V2 = "/api/v2";
62
63     // WebSocket helper classes
64     private final WebSocketRemote webSocketRemote;
65     private final WebSocketArt webSocketArt;
66     private final WebSocketV2 webSocketV2;
67
68     // refresh limit for current app update (in seconds)
69     private static final long UPDATE_CURRENT_APP_REFRESH_SECONDS = 10;
70     private Instant previousUpdateCurrentApp = Instant.MIN;
71
72     // JSON parser class. Also used by WebSocket handlers.
73     public final Gson gson = new Gson();
74
75     // Callback class. Also used by WebSocket handlers.
76     final RemoteControllerService callback;
77
78     // Websocket client class shared by WebSocket handlers.
79     final WebSocketClient client;
80
81     // App File servicce
82     private final SamsungTvAppWatchService samsungTvAppWatchService;
83
84     // list instaled apps after 2 updates
85     public int updateCount = 0;
86
87     // UUID used for data exchange via websockets
88     final UUID uuid = UUID.randomUUID();
89
90     // Description of Apps
91     public class App {
92         public String appId;
93         public String name;
94         public int type;
95
96         App(String appId, String name, int type) {
97             this.appId = appId;
98             this.name = name;
99             this.type = type;
100         }
101
102         @Override
103         public String toString() {
104             return this.name;
105         }
106
107         public String getAppId() {
108             return appId != null ? appId : "";
109         }
110
111         public String getName() {
112             return name != null ? name : "";
113         }
114
115         public void setName(String name) {
116             this.name = name;
117         }
118
119         public int getType() {
120             return Optional.ofNullable(type).orElse(2);
121         }
122     }
123
124     // Map of all available apps
125     public Map<String, App> apps = new ConcurrentHashMap<>();
126     // manually added apps (from File)
127     public Map<String, App> manApps = new ConcurrentHashMap<>();
128
129     /**
130      * The {@link Action} presents available actions for keys with Samsung TV.
131      *
132      */
133     public static enum Action {
134
135         CLICK("Click"),
136         PRESS("Press"),
137         RELEASE("Release"),
138         MOVE("Move"),
139         END("End"),
140         TEXT("Text"),
141         MOUSECLICK("MouseClick");
142
143         private final String value;
144
145         Action() {
146             value = "Click";
147         }
148
149         Action(String newvalue) {
150             this.value = newvalue;
151         }
152
153         @Override
154         public String toString() {
155             return value;
156         }
157     }
158
159     /**
160      * Create and initialize remote controller instance.
161      *
162      * @param host Host name of the Samsung TV.
163      * @param port TCP port of the remote controller protocol.
164      * @param appName Application name used to send key codes.
165      * @param uniqueId Unique Id used to send key codes.
166      * @param callback RemoteControllerService callback
167      * @throws RemoteControllerException
168      */
169     public RemoteControllerWebSocket(String host, int port, String appName, String uniqueId,
170             RemoteControllerService callback) throws RemoteControllerException {
171         super(host, port, appName, uniqueId);
172         this.callback = callback;
173
174         WebSocketFactory webSocketFactory = callback.getWebSocketFactory();
175         if (webSocketFactory == null) {
176             throw new RemoteControllerException("No WebSocketFactory available");
177         }
178
179         this.samsungTvAppWatchService = new SamsungTvAppWatchService(host, this);
180
181         SslContextFactory sslContextFactory = new SslContextFactory.Client( /* trustall= */ true);
182         /* remove extra filters added by jetty on cipher suites */
183         sslContextFactory.setExcludeCipherSuites();
184         client = webSocketFactory.createWebSocketClient("samsungtv", sslContextFactory);
185         client.addLifeCycleListener(this);
186
187         webSocketRemote = new WebSocketRemote(this);
188         webSocketArt = new WebSocketArt(this);
189         webSocketV2 = new WebSocketV2(this);
190     }
191
192     public boolean isConnected() {
193         if (callback.getArtModeSupported()) {
194             return webSocketRemote.isConnected() && webSocketArt.isConnected();
195         }
196         return webSocketRemote.isConnected();
197     }
198
199     public void openConnection() throws RemoteControllerException {
200         logger.trace("{}: openConnection()", host);
201
202         if (!(client.isStarted() || client.isStarting())) {
203             logger.debug("{}: RemoteControllerWebSocket start Client", host);
204             try {
205                 client.start();
206                 client.setMaxBinaryMessageBufferSize(1024 * 1024);
207                 // websocket connect will be done in lifetime handler
208                 return;
209             } catch (Exception e) {
210                 logger.warn("{}: Cannot connect to websocket remote control interface: {}", host, e.getMessage());
211                 throw new RemoteControllerException(e);
212             }
213         }
214         connectWebSockets();
215     }
216
217     private void logResult(String msg, Throwable cause) {
218         if (logger.isTraceEnabled()) {
219             logger.trace("{}: {}: ", host, msg, cause);
220         } else {
221             logger.warn("{}: {}: {}", host, msg, cause.getMessage());
222         }
223     }
224
225     private void connectWebSockets() {
226         logger.trace("{}: connectWebSockets()", host);
227
228         String encodedAppName = Utils.b64encode(appName);
229
230         String protocol = PROTOCOL_SECUREWEBSOCKET.equals(callback.handler.configuration.getProtocol()) ? "wss" : "ws";
231         try {
232             String token = callback.handler.configuration.getWebsocketToken();
233             if ("wss".equals(protocol) && token.isBlank()) {
234                 logger.warn(
235                         "{}: WebSocketRemote connecting without Token, please accept the connection on the TV within 30 seconds",
236                         host);
237             }
238             webSocketRemote.connect(new URI(protocol, null, host, port, WS_ENDPOINT_REMOTE_CONTROL,
239                     "name=" + encodedAppName + (token.isBlank() ? "" : "&token=" + token), null));
240         } catch (RemoteControllerException | URISyntaxException e) {
241             logResult("Problem connecting to remote websocket", e);
242         }
243
244         try {
245             webSocketArt.connect(new URI(protocol, null, host, port, WS_ENDPOINT_ART, "name=" + encodedAppName, null));
246         } catch (RemoteControllerException | URISyntaxException e) {
247             logResult("Problem connecting to artmode websocket", e);
248         }
249
250         try {
251             webSocketV2.connect(new URI(protocol, null, host, port, WS_ENDPOINT_V2, "name=" + encodedAppName, null));
252         } catch (RemoteControllerException | URISyntaxException e) {
253             logResult("Problem connecting to V2 websocket", e);
254         }
255     }
256
257     private void closeConnection() throws RemoteControllerException {
258         logger.debug("{}: RemoteControllerWebSocket closeConnection", host);
259
260         try {
261             webSocketRemote.close();
262             webSocketArt.close();
263             webSocketV2.close();
264             client.stop();
265         } catch (Exception e) {
266             throw new RemoteControllerException(e);
267         }
268     }
269
270     @Override
271     public void close() throws RemoteControllerException {
272         logger.debug("{}: RemoteControllerWebSocket close", host);
273         closeConnection();
274     }
275
276     public boolean noApps() {
277         return apps.isEmpty();
278     }
279
280     public void listApps() {
281         Stream<Map.Entry<String, App>> st = (noApps()) ? manApps.entrySet().stream() : apps.entrySet().stream();
282         logger.debug("{}: Installed Apps: {}", host,
283                 st.map(entry -> entry.getValue().appId + " = " + entry.getKey()).collect(Collectors.joining(", ")));
284     }
285
286     /**
287      * Retrieve app status for all apps. In the WebSocketv2 handler the currently running app will be determined
288      */
289     public synchronized void updateCurrentApp() {
290         // limit noApp refresh rate
291         if (noApps()
292                 && Instant.now().isBefore(previousUpdateCurrentApp.plusSeconds(UPDATE_CURRENT_APP_REFRESH_SECONDS))) {
293             return;
294         }
295         previousUpdateCurrentApp = Instant.now();
296         if (webSocketV2.isNotConnected()) {
297             logger.warn("{}: Cannot retrieve current app webSocketV2 is not connected", host);
298             return;
299         }
300         // if noapps by this point, start file app service
301         if (updateCount >= 1 && noApps() && !samsungTvAppWatchService.getStarted()) {
302             samsungTvAppWatchService.start();
303         }
304         // list apps
305         if (updateCount++ == 2) {
306             listApps();
307         }
308         for (App app : (noApps()) ? manApps.values() : apps.values()) {
309             webSocketV2.getAppStatus(app.getAppId());
310             // prevent being called again if this takes a while
311             previousUpdateCurrentApp = Instant.now();
312         }
313     }
314
315     /**
316      * Update manual App list from file (called from SamsungTvAppWatchService)
317      */
318     public void updateAppList(List<String> fileApps) {
319         previousUpdateCurrentApp = Instant.now();
320         manApps.clear();
321         fileApps.forEach(line -> {
322             try {
323                 App app = gson.fromJson(line, App.class);
324                 if (app != null) {
325                     manApps.put(app.getName(), new App(app.getAppId(), app.getName(), app.getType()));
326                     logger.debug("{}: Added app: {}/{}", host, app.getName(), app.getAppId());
327                 }
328             } catch (JsonSyntaxException e) {
329                 logger.warn("{}: cannot add app, wrong format {}: {}", host, line, e.getMessage());
330             }
331         });
332         addKnownAppIds();
333         updateCount = 0;
334     }
335
336     /**
337      * Add all know app id's to manApps
338      */
339     public void addKnownAppIds() {
340         KnownAppId.stream().filter(id -> !manApps.values().stream().anyMatch(a -> a.getAppId().equals(id)))
341                 .forEach(id -> {
342                     previousUpdateCurrentApp = Instant.now();
343                     manApps.put(id, new App(id, id, 2));
344                     logger.debug("{}: Added Known appId: {}", host, id);
345                 });
346     }
347
348     /**
349      * Send key code to Samsung TV.
350      *
351      * @param key Key code to send.
352      */
353     public void sendKey(Object key) {
354         if (key instanceof KeyCode keyAsKeyCode) {
355             sendKey(keyAsKeyCode, Action.CLICK);
356         } else if (key instanceof String) {
357             sendKey((String) key);
358         }
359     }
360
361     public void sendKey(String value) {
362         try {
363             if (value.startsWith("{")) {
364                 sendKeyData(value, Action.MOVE);
365             } else if ("LeftClick".equals(value) || "RightClick".equals(value)) {
366                 sendKeyData(value, Action.MOUSECLICK);
367             } else if (value.isEmpty()) {
368                 sendKeyData("", Action.END);
369             } else {
370                 sendKeyData(value, Action.TEXT);
371             }
372         } catch (RemoteControllerException e) {
373             logger.debug("{}: Couldn't send Text/Mouse move {}", host, e.getMessage());
374         }
375     }
376
377     public void sendKey(KeyCode key, Action action) {
378         try {
379             sendKeyData(key, action);
380         } catch (RemoteControllerException e) {
381             logger.debug("{}: Couldn't send command {}", host, e.getMessage());
382         }
383     }
384
385     public void sendKeyPress(KeyCode key, int duration) {
386         sendKey(key, Action.PRESS);
387         // send key release in duration milliseconds
388         @Nullable
389         ScheduledExecutorService scheduler = callback.getScheduler();
390         if (scheduler != null) {
391             scheduler.schedule(() -> {
392                 if (isConnected()) {
393                     sendKey(key, Action.RELEASE);
394                 }
395             }, duration, TimeUnit.MILLISECONDS);
396         }
397     }
398
399     private void sendKeyData(Object key, Action action) throws RemoteControllerException {
400         logger.debug("{}: Try to send Key: {}, Action: {}", host, key, action);
401         webSocketRemote.sendKeyData(action.toString(), key.toString());
402     }
403
404     public void sendSourceApp(String appName) {
405         if (appName.toLowerCase().contains("slideshow")) {
406             webSocketArt.setSlideshow(appName);
407         } else {
408             sendSourceApp(appName, null);
409         }
410     }
411
412     public void sendSourceApp(final String appName, @Nullable String url) {
413         Stream<Map.Entry<String, App>> st = (noApps()) ? manApps.entrySet().stream() : apps.entrySet().stream();
414         boolean found = st.filter(a -> a.getKey().equals(appName) || a.getValue().name.equals(appName))
415                 .map(a -> sendSourceApp(a.getValue().appId, a.getValue().type == 2, url)).findFirst().orElse(false);
416         if (!found) {
417             // treat appName as appId with optional type number eg "3201907018807, 2"
418             String[] appArray = (url == null) ? appName.trim().split(",") : "org.tizen.browser,4".split(",");
419             sendSourceApp(appArray[0].trim(), (appArray.length > 1) ? "2".equals(appArray[1].trim()) : true, url);
420         }
421     }
422
423     public boolean sendSourceApp(String appId, boolean type, @Nullable String url) {
424         if (noApps()) {
425             // 2020 TV's and later use webSocketV2 for app launch
426             webSocketV2.sendSourceApp(appId, type, url);
427         } else {
428             if (webSocketV2.isConnected() && url == null) {
429                 // it seems all Tizen TV's can use webSocketV2 if it connects
430                 webSocketV2.sendSourceApp(appId, type, url);
431             } else {
432                 webSocketRemote.sendSourceApp(appId, type, url);
433             }
434         }
435         return true;
436     }
437
438     public void sendUrl(String url) {
439         String processedUrl = url.replace("/", "\\/");
440         sendSourceApp("Internet", processedUrl);
441     }
442
443     public boolean closeApp() {
444         return webSocketV2.closeApp();
445     }
446
447     /**
448      * Get app status after 3 second delay (apps take 3s to launch)
449      */
450     public void getAppStatus(String id) {
451         @Nullable
452         ScheduledExecutorService scheduler = callback.getScheduler();
453         if (scheduler != null) {
454             scheduler.schedule(() -> {
455                 if (webSocketV2.isConnected()) {
456                     if (!id.isBlank()) {
457                         webSocketV2.getAppStatus(id);
458                     } else {
459                         updateCurrentApp();
460                     }
461                 }
462             }, 3000, TimeUnit.MILLISECONDS);
463         }
464     }
465
466     public void getArtmodeStatus(String... optionalRequests) {
467         webSocketArt.getArtmodeStatus(optionalRequests);
468     }
469
470     @Override
471     public void lifeCycleStarted(@Nullable LifeCycle arg0) {
472         logger.trace("{}: WebSocketClient started", host);
473         connectWebSockets();
474     }
475
476     @Override
477     public void lifeCycleFailure(@Nullable LifeCycle arg0, @Nullable Throwable throwable) {
478         logger.warn("{}: WebSocketClient failure: {}", host, throwable != null ? throwable.toString() : null);
479     }
480
481     @Override
482     public void lifeCycleStarting(@Nullable LifeCycle arg0) {
483         logger.trace("{}: WebSocketClient starting", host);
484     }
485
486     @Override
487     public void lifeCycleStopped(@Nullable LifeCycle arg0) {
488         logger.trace("{}: WebSocketClient stopped", host);
489     }
490
491     @Override
492     public void lifeCycleStopping(@Nullable LifeCycle arg0) {
493         logger.trace("{}: WebSocketClient stopping", host);
494     }
495 }