2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.samsungtv.internal.protocol;
15 import static org.openhab.binding.samsungtv.internal.config.SamsungTvConfiguration.*;
18 import java.net.URISyntaxException;
19 import java.time.Instant;
20 import java.util.List;
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;
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;
43 import com.google.gson.Gson;
44 import com.google.gson.JsonSyntaxException;
47 * The {@link RemoteControllerWebSocket} is responsible for sending key codes to the
48 * Samsung TV via the websocket protocol (for newer TV's).
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
55 public class RemoteControllerWebSocket extends RemoteController implements Listener {
57 private final Logger logger = LoggerFactory.getLogger(RemoteControllerWebSocket.class);
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";
63 // WebSocket helper classes
64 private final WebSocketRemote webSocketRemote;
65 private final WebSocketArt webSocketArt;
66 private final WebSocketV2 webSocketV2;
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;
72 // JSON parser class. Also used by WebSocket handlers.
73 public final Gson gson = new Gson();
75 // Callback class. Also used by WebSocket handlers.
76 final RemoteControllerService callback;
78 // Websocket client class shared by WebSocket handlers.
79 final WebSocketClient client;
82 private final SamsungTvAppWatchService samsungTvAppWatchService;
84 // list instaled apps after 2 updates
85 public int updateCount = 0;
87 // UUID used for data exchange via websockets
88 final UUID uuid = UUID.randomUUID();
90 // Description of Apps
96 App(String appId, String name, int type) {
103 public String toString() {
107 public String getAppId() {
108 return appId != null ? appId : "";
111 public String getName() {
112 return name != null ? name : "";
115 public void setName(String name) {
119 public int getType() {
120 return Optional.ofNullable(type).orElse(2);
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<>();
130 * The {@link Action} presents available actions for keys with Samsung TV.
133 public static enum Action {
141 MOUSECLICK("MouseClick");
143 private final String value;
149 Action(String newvalue) {
150 this.value = newvalue;
154 public String toString() {
160 * Create and initialize remote controller instance.
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
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;
174 WebSocketFactory webSocketFactory = callback.getWebSocketFactory();
175 if (webSocketFactory == null) {
176 throw new RemoteControllerException("No WebSocketFactory available");
179 this.samsungTvAppWatchService = new SamsungTvAppWatchService(host, this);
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);
187 webSocketRemote = new WebSocketRemote(this);
188 webSocketArt = new WebSocketArt(this);
189 webSocketV2 = new WebSocketV2(this);
192 public boolean isConnected() {
193 if (callback.getArtModeSupported()) {
194 return webSocketRemote.isConnected() && webSocketArt.isConnected();
196 return webSocketRemote.isConnected();
199 public void openConnection() throws RemoteControllerException {
200 logger.trace("{}: openConnection()", host);
202 if (!(client.isStarted() || client.isStarting())) {
203 logger.debug("{}: RemoteControllerWebSocket start Client", host);
206 client.setMaxBinaryMessageBufferSize(1024 * 1024);
207 // websocket connect will be done in lifetime handler
209 } catch (Exception e) {
210 logger.warn("{}: Cannot connect to websocket remote control interface: {}", host, e.getMessage());
211 throw new RemoteControllerException(e);
217 private void logResult(String msg, Throwable cause) {
218 if (logger.isTraceEnabled()) {
219 logger.trace("{}: {}: ", host, msg, cause);
221 logger.warn("{}: {}: {}", host, msg, cause.getMessage());
225 private void connectWebSockets() {
226 logger.trace("{}: connectWebSockets()", host);
228 String encodedAppName = Utils.b64encode(appName);
230 String protocol = PROTOCOL_SECUREWEBSOCKET.equals(callback.handler.configuration.getProtocol()) ? "wss" : "ws";
232 String token = callback.handler.configuration.getWebsocketToken();
233 if ("wss".equals(protocol) && token.isBlank()) {
235 "{}: WebSocketRemote connecting without Token, please accept the connection on the TV within 30 seconds",
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);
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);
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);
257 private void closeConnection() throws RemoteControllerException {
258 logger.debug("{}: RemoteControllerWebSocket closeConnection", host);
261 webSocketRemote.close();
262 webSocketArt.close();
265 } catch (Exception e) {
266 throw new RemoteControllerException(e);
271 public void close() throws RemoteControllerException {
272 logger.debug("{}: RemoteControllerWebSocket close", host);
276 public boolean noApps() {
277 return apps.isEmpty();
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(", ")));
287 * Retrieve app status for all apps. In the WebSocketv2 handler the currently running app will be determined
289 public synchronized void updateCurrentApp() {
290 // limit noApp refresh rate
292 && Instant.now().isBefore(previousUpdateCurrentApp.plusSeconds(UPDATE_CURRENT_APP_REFRESH_SECONDS))) {
295 previousUpdateCurrentApp = Instant.now();
296 if (webSocketV2.isNotConnected()) {
297 logger.warn("{}: Cannot retrieve current app webSocketV2 is not connected", host);
300 // if noapps by this point, start file app service
301 if (updateCount >= 1 && noApps() && !samsungTvAppWatchService.getStarted()) {
302 samsungTvAppWatchService.start();
305 if (updateCount++ == 2) {
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();
316 * Update manual App list from file (called from SamsungTvAppWatchService)
318 public void updateAppList(List<String> fileApps) {
319 previousUpdateCurrentApp = Instant.now();
321 fileApps.forEach(line -> {
323 App app = gson.fromJson(line, App.class);
325 manApps.put(app.getName(), new App(app.getAppId(), app.getName(), app.getType()));
326 logger.debug("{}: Added app: {}/{}", host, app.getName(), app.getAppId());
328 } catch (JsonSyntaxException e) {
329 logger.warn("{}: cannot add app, wrong format {}: {}", host, line, e.getMessage());
337 * Add all know app id's to manApps
339 public void addKnownAppIds() {
340 KnownAppId.stream().filter(id -> !manApps.values().stream().anyMatch(a -> a.getAppId().equals(id)))
342 previousUpdateCurrentApp = Instant.now();
343 manApps.put(id, new App(id, id, 2));
344 logger.debug("{}: Added Known appId: {}", host, id);
349 * Send key code to Samsung TV.
351 * @param key Key code to send.
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);
361 public void sendKey(String value) {
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);
370 sendKeyData(value, Action.TEXT);
372 } catch (RemoteControllerException e) {
373 logger.debug("{}: Couldn't send Text/Mouse move {}", host, e.getMessage());
377 public void sendKey(KeyCode key, Action action) {
379 sendKeyData(key, action);
380 } catch (RemoteControllerException e) {
381 logger.debug("{}: Couldn't send command {}", host, e.getMessage());
385 public void sendKeyPress(KeyCode key, int duration) {
386 sendKey(key, Action.PRESS);
387 // send key release in duration milliseconds
389 ScheduledExecutorService scheduler = callback.getScheduler();
390 if (scheduler != null) {
391 scheduler.schedule(() -> {
393 sendKey(key, Action.RELEASE);
395 }, duration, TimeUnit.MILLISECONDS);
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());
404 public void sendSourceApp(String appName) {
405 if (appName.toLowerCase().contains("slideshow")) {
406 webSocketArt.setSlideshow(appName);
408 sendSourceApp(appName, null);
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);
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);
423 public boolean sendSourceApp(String appId, boolean type, @Nullable String url) {
425 // 2020 TV's and later use webSocketV2 for app launch
426 webSocketV2.sendSourceApp(appId, type, url);
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);
432 webSocketRemote.sendSourceApp(appId, type, url);
438 public void sendUrl(String url) {
439 String processedUrl = url.replace("/", "\\/");
440 sendSourceApp("Internet", processedUrl);
443 public boolean closeApp() {
444 return webSocketV2.closeApp();
448 * Get app status after 3 second delay (apps take 3s to launch)
450 public void getAppStatus(String id) {
452 ScheduledExecutorService scheduler = callback.getScheduler();
453 if (scheduler != null) {
454 scheduler.schedule(() -> {
455 if (webSocketV2.isConnected()) {
457 webSocketV2.getAppStatus(id);
462 }, 3000, TimeUnit.MILLISECONDS);
466 public void getArtmodeStatus(String... optionalRequests) {
467 webSocketArt.getArtmodeStatus(optionalRequests);
471 public void lifeCycleStarted(@Nullable LifeCycle arg0) {
472 logger.trace("{}: WebSocketClient started", host);
477 public void lifeCycleFailure(@Nullable LifeCycle arg0, @Nullable Throwable throwable) {
478 logger.warn("{}: WebSocketClient failure: {}", host, throwable != null ? throwable.toString() : null);
482 public void lifeCycleStarting(@Nullable LifeCycle arg0) {
483 logger.trace("{}: WebSocketClient starting", host);
487 public void lifeCycleStopped(@Nullable LifeCycle arg0) {
488 logger.trace("{}: WebSocketClient stopped", host);
492 public void lifeCycleStopping(@Nullable LifeCycle arg0) {
493 logger.trace("{}: WebSocketClient stopping", host);