]> git.basschouten.com Git - openhab-addons.git/blob
00b829aeff28498310d573a6301f97d39bdf0f3c
[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 /*
14  * This file is based on:
15  *
16  * WebOSTVService
17  * Connect SDK
18  *
19  * Copyright (c) 2014 LG Electronics.
20  * Created by Hyun Kook Khang on 19 Jan 2014
21  *
22  * Licensed under the Apache License, Version 2.0 (the "License");
23  * you may not use this file except in compliance with the License.
24  * You may obtain a copy of the License at
25  *
26  *     http://www.apache.org/licenses/LICENSE-2.0
27  *
28  * Unless required by applicable law or agreed to in writing, software
29  * distributed under the License is distributed on an "AS IS" BASIS,
30  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
31  * See the License for the specific language governing permissions and
32  * limitations under the License.
33  */
34 package org.openhab.binding.lgwebos.internal.handler;
35
36 import static org.openhab.binding.lgwebos.internal.LGWebOSBindingConstants.*;
37
38 import java.io.IOException;
39 import java.net.ConnectException;
40 import java.net.SocketTimeoutException;
41 import java.net.URI;
42 import java.net.URISyntaxException;
43 import java.time.Instant;
44 import java.util.ArrayList;
45 import java.util.HashMap;
46 import java.util.List;
47 import java.util.Locale;
48 import java.util.Map;
49 import java.util.Map.Entry;
50 import java.util.Optional;
51 import java.util.concurrent.Future;
52 import java.util.concurrent.ScheduledExecutorService;
53 import java.util.concurrent.ScheduledFuture;
54 import java.util.concurrent.TimeUnit;
55 import java.util.function.Consumer;
56
57 import org.eclipse.jdt.annotation.NonNullByDefault;
58 import org.eclipse.jdt.annotation.Nullable;
59 import org.eclipse.jetty.websocket.api.Session;
60 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
61 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
62 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
63 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
64 import org.eclipse.jetty.websocket.api.annotations.WebSocket;
65 import org.eclipse.jetty.websocket.client.WebSocketClient;
66 import org.openhab.binding.lgwebos.internal.handler.LGWebOSTVMouseSocket.WebOSTVMouseSocketListener;
67 import org.openhab.binding.lgwebos.internal.handler.command.ServiceCommand;
68 import org.openhab.binding.lgwebos.internal.handler.command.ServiceSubscription;
69 import org.openhab.binding.lgwebos.internal.handler.core.AppInfo;
70 import org.openhab.binding.lgwebos.internal.handler.core.ChannelInfo;
71 import org.openhab.binding.lgwebos.internal.handler.core.CommandConfirmation;
72 import org.openhab.binding.lgwebos.internal.handler.core.LaunchSession;
73 import org.openhab.binding.lgwebos.internal.handler.core.LaunchSession.LaunchSessionType;
74 import org.openhab.binding.lgwebos.internal.handler.core.Response;
75 import org.openhab.binding.lgwebos.internal.handler.core.ResponseListener;
76 import org.openhab.binding.lgwebos.internal.handler.core.TextInputStatusInfo;
77 import org.slf4j.Logger;
78 import org.slf4j.LoggerFactory;
79
80 import com.google.gson.Gson;
81 import com.google.gson.GsonBuilder;
82 import com.google.gson.JsonElement;
83 import com.google.gson.JsonObject;
84 import com.google.gson.reflect.TypeToken;
85
86 /**
87  * WebSocket to handle the communication with WebOS device.
88  *
89  * @author Hyun Kook Khang - Initial contribution
90  * @author Sebastian Prehn - Web Socket implementation and adoption for openHAB
91  */
92 @WebSocket()
93 @NonNullByDefault
94 public class LGWebOSTVSocket {
95
96     private static final String FOREGROUND_APP = "ssap://com.webos.applicationManager/getForegroundAppInfo";
97     // private static final String APP_STATUS = "ssap://com.webos.service.appstatus/getAppStatus";
98     // private static final String APP_STATE = "ssap://system.launcher/getAppState";
99     private static final String VOLUME = "ssap://audio/getVolume";
100     private static final String MUTE = "ssap://audio/getMute";
101     // private static final String VOLUME_STATUS = "ssap://audio/getStatus";
102     private static final String CHANNEL_LIST = "ssap://tv/getChannelList";
103     private static final String CHANNEL = "ssap://tv/getCurrentChannel";
104     // private static final String PROGRAM = "ssap://tv/getChannelProgramInfo";
105     // private static final String CURRENT_PROGRAM = "ssap://tv/getChannelCurrentProgramInfo";
106     // private static final String THREED_STATUS = "ssap://com.webos.service.tv.display/get3DStatus";
107
108     private static final int DISCONNECTING_DELAY_SECONDS = 2;
109
110     private static final Gson GSON = new GsonBuilder().create();
111
112     private final Logger logger = LoggerFactory.getLogger(LGWebOSTVSocket.class);
113
114     private final ConfigProvider config;
115     private final WebSocketClient client;
116     private final URI destUri;
117     private final LGWebOSTVKeyboardInput keyboardInput;
118     private final ScheduledExecutorService scheduler;
119     private final Protocol protocol;
120
121     public enum State {
122         DISCONNECTING,
123         DISCONNECTED,
124         CONNECTING,
125         REGISTERING,
126         REGISTERED
127     }
128
129     private enum Protocol {
130         WEB_SOCKET("ws", DEFAULT_WS_PORT),
131         WEB_SOCKET_SECURE("wss", DEFAULT_WSS_PORT);
132
133         private Protocol(String name, int port) {
134             this.name = name;
135             this.port = port;
136         }
137
138         public String name;
139         public int port;
140     }
141
142     private State state = State.DISCONNECTED;
143
144     private @Nullable Session session;
145     private @Nullable Future<?> sessionFuture;
146     private @Nullable WebOSTVSocketListener listener;
147
148     /**
149      * Requests to which we are awaiting response.
150      */
151     private HashMap<Integer, ServiceCommand<?>> requests = new HashMap<>();
152
153     private int nextRequestId = 0;
154
155     private @Nullable ScheduledFuture<?> disconnectingJob;
156
157     public LGWebOSTVSocket(WebSocketClient client, ConfigProvider config, String host, boolean useTLS,
158             ScheduledExecutorService scheduler) {
159         this.config = config;
160         this.client = client;
161         this.keyboardInput = new LGWebOSTVKeyboardInput(this);
162         this.protocol = useTLS ? Protocol.WEB_SOCKET_SECURE : Protocol.WEB_SOCKET;
163
164         try {
165             this.destUri = new URI(protocol.name + "://" + host + ":" + protocol.port);
166         } catch (URISyntaxException e) {
167             throw new IllegalArgumentException("IP address or hostname provided is invalid: " + host);
168         }
169
170         this.scheduler = scheduler;
171     }
172
173     public State getState() {
174         return state;
175     }
176
177     private void setState(State state) {
178         logger.debug("setState new {} - current {}", state, this.state);
179         State oldState = this.state;
180         if (oldState != state) {
181             this.state = state;
182             Optional.ofNullable(this.listener).ifPresent(l -> l.onStateChanged(this.state));
183         }
184     }
185
186     public void setListener(@Nullable WebOSTVSocketListener listener) {
187         this.listener = listener;
188     }
189
190     public void clearRequests() {
191         requests.clear();
192     }
193
194     public void connect() {
195         try {
196             sessionFuture = this.client.connect(this, this.destUri);
197             logger.debug("Connecting to: {}", this.destUri);
198         } catch (IOException e) {
199             logger.debug("Unable to connect.", e);
200         }
201     }
202
203     public void disconnect() {
204         Optional.ofNullable(this.session).ifPresent(s -> s.close());
205         Future<?> future = sessionFuture;
206         if (future != null && !future.isDone()) {
207             future.cancel(true);
208         }
209         stopDisconnectingJob();
210         setState(State.DISCONNECTED);
211     }
212
213     private void disconnecting() {
214         logger.debug("disconnecting");
215         if (state == State.REGISTERED) {
216             setState(State.DISCONNECTING);
217         }
218     }
219
220     private void scheduleDisconectingJob() {
221         ScheduledFuture<?> job = disconnectingJob;
222         if (job == null || job.isCancelled()) {
223             logger.debug("Schedule disconecting job");
224             disconnectingJob = scheduler.schedule(this::disconnecting, DISCONNECTING_DELAY_SECONDS, TimeUnit.SECONDS);
225         }
226     }
227
228     private void stopDisconnectingJob() {
229         ScheduledFuture<?> job = disconnectingJob;
230         if (job != null && !job.isCancelled()) {
231             logger.debug("Stop disconnecting job");
232             job.cancel(true);
233         }
234         disconnectingJob = null;
235     }
236
237     /*
238      * WebSocket Callbacks
239      */
240
241     @OnWebSocketConnect
242     public void onConnect(Session session) {
243         logger.debug("WebSocket Connected to: {}", session.getRemoteAddress().getAddress());
244         this.session = session;
245         sendHello();
246     }
247
248     @OnWebSocketError
249     public void onError(Throwable cause) {
250         logger.trace("Connection Error", cause);
251         if (cause instanceof SocketTimeoutException && "Connect Timeout".equals(cause.getMessage())) {
252             // this is expected during connection attempts while TV is off
253             setState(State.DISCONNECTED);
254             return;
255         }
256         if (cause instanceof ConnectException && "Connection refused".equals(cause.getMessage())) {
257             // this is expected during TV startup or shutdown
258             return;
259         }
260
261         String message = cause.getMessage();
262         Optional.ofNullable(this.listener).ifPresent(l -> l.onError(message != null ? message : ""));
263     }
264
265     @OnWebSocketClose
266     public void onClose(int statusCode, String reason) {
267         logger.debug("WebSocket Closed - Code: {}, Reason: {}", statusCode, reason);
268         this.requests.clear();
269         this.session = null;
270         setState(State.DISCONNECTED);
271     }
272
273     /*
274      * WebOS WebSocket API specific Communication
275      */
276     void sendHello() {
277         setState(State.CONNECTING);
278
279         JsonObject packet = new JsonObject();
280         packet.addProperty("id", nextRequestId());
281         packet.addProperty("type", "hello");
282
283         JsonObject payload = new JsonObject();
284         payload.addProperty("appId", "org.openhab");
285         payload.addProperty("appName", "openHAB");
286         payload.addProperty("appRegion", Locale.getDefault().getDisplayCountry());
287         packet.add("payload", payload);
288         // the hello response will not contain id, therefore not registering in requests
289         sendMessage(packet);
290     }
291
292     void sendRegister() {
293         setState(State.REGISTERING);
294
295         JsonObject packet = new JsonObject();
296         int id = nextRequestId();
297         packet.addProperty("id", id);
298         packet.addProperty("type", "register");
299
300         JsonObject manifest = new JsonObject();
301         manifest.addProperty("manifestVersion", 1);
302
303         String[] permissions = { "LAUNCH", "LAUNCH_WEBAPP", "APP_TO_APP", "CONTROL_AUDIO",
304                 "CONTROL_INPUT_MEDIA_PLAYBACK", "CONTROL_POWER", "READ_INSTALLED_APPS", "CONTROL_DISPLAY",
305                 "CONTROL_INPUT_JOYSTICK", "CONTROL_INPUT_MEDIA_RECORDING", "CONTROL_INPUT_TV", "READ_INPUT_DEVICE_LIST",
306                 "READ_NETWORK_STATE", "READ_TV_CHANNEL_LIST", "WRITE_NOTIFICATION_TOAST", "CONTROL_INPUT_TEXT",
307                 "CONTROL_MOUSE_AND_KEYBOARD", "READ_CURRENT_CHANNEL", "READ_RUNNING_APPS" };
308
309         manifest.add("permissions", GSON.toJsonTree(permissions));
310
311         JsonObject payload = new JsonObject();
312         String key = config.getKey();
313         if (!key.isEmpty()) {
314             payload.addProperty("client-key", key);
315         }
316         payload.addProperty("pairingType", "PROMPT"); // PIN, COMBINED
317         payload.add("manifest", manifest);
318         packet.add("payload", payload);
319         ResponseListener<JsonObject> dummyListener = new ResponseListener<>() {
320
321             @Override
322             public void onSuccess(@Nullable JsonObject payload) {
323                 // Noting to do here. TV shows PROMPT dialog.
324                 // Waiting for message of type error or registered
325             }
326
327             @Override
328             public void onError(String message) {
329                 logger.debug("Registration failed with message: {}", message);
330                 disconnect();
331             }
332         };
333
334         this.requests.put(id, new ServiceSubscription<>("dummy", payload, x -> x, dummyListener));
335         sendMessage(packet, !key.isEmpty());
336     }
337
338     private int nextRequestId() {
339         int requestId;
340         do {
341             requestId = nextRequestId++;
342         } while (requests.containsKey(requestId));
343         return requestId;
344     }
345
346     public void sendCommand(ServiceCommand<?> command) {
347         switch (state) {
348             case REGISTERED:
349                 int requestId = nextRequestId();
350                 requests.put(requestId, command);
351                 JsonObject packet = new JsonObject();
352                 packet.addProperty("type", command.getType());
353                 packet.addProperty("id", requestId);
354                 packet.addProperty("uri", command.getTarget());
355                 JsonElement payload = command.getPayload();
356                 if (payload != null) {
357                     packet.add("payload", payload);
358                 }
359                 this.sendMessage(packet);
360
361                 break;
362             case CONNECTING:
363             case REGISTERING:
364             case DISCONNECTING:
365             case DISCONNECTED:
366                 logger.debug("Skipping {} command {} for {} in state {}", command.getType(), command,
367                         command.getTarget(), state);
368                 break;
369         }
370     }
371
372     public void unsubscribe(ServiceSubscription<?> subscription) {
373         Optional<Entry<Integer, ServiceCommand<?>>> entry = this.requests.entrySet().stream()
374                 .filter(e -> e.getValue().equals(subscription)).findFirst();
375         if (entry.isPresent()) {
376             int requestId = entry.get().getKey();
377             this.requests.remove(requestId);
378             JsonObject packet = new JsonObject();
379             packet.addProperty("type", "unsubscribe");
380             packet.addProperty("id", requestId);
381             sendMessage(packet);
382         }
383     }
384
385     private void sendMessage(JsonObject json) {
386         sendMessage(json, false);
387     }
388
389     private void sendMessage(JsonObject json, boolean checkKey) {
390         String msg = GSON.toJson(json);
391         Session s = this.session;
392         try {
393             if (s != null) {
394                 if (logger.isTraceEnabled()) {
395                     logger.trace("Message [out]: {}", checkKey ? GSON.toJson(maskKeyInJson(json)) : msg);
396                 }
397                 s.getRemote().sendString(msg);
398             } else {
399                 logger.warn("No Connection to TV, skipping [out]: {}",
400                         checkKey ? GSON.toJson(maskKeyInJson(json)) : msg);
401             }
402         } catch (IOException e) {
403             logger.warn("Unable to send message.", e);
404         }
405     }
406
407     private JsonObject maskKeyInJson(JsonObject json) {
408         if (json.has("payload") && json.getAsJsonObject("payload").has("client-key")) {
409             JsonObject jsonCopy = json.deepCopy();
410             JsonObject payload = jsonCopy.getAsJsonObject("payload");
411             payload.remove("client-key");
412             payload.addProperty("client-key", "***");
413             return jsonCopy;
414         }
415         return json;
416     }
417
418     @OnWebSocketMessage
419     public void onMessage(String message) {
420         Response response = GSON.fromJson(message, Response.class);
421         JsonElement payload = response.getPayload();
422         JsonObject jsonPayload = payload == null ? null : payload.getAsJsonObject();
423         String messageToLog = (jsonPayload != null && jsonPayload.has("client-key")) ? "***" : message;
424         logger.trace("Message [in]: {}", messageToLog);
425         ServiceCommand<?> request = null;
426
427         if (response.getId() != null) {
428             request = requests.get(response.getId());
429             if (request == null) {
430                 logger.warn("Received a response with id {}, for which no request was found. This should not happen.",
431                         response.getId());
432             } else {
433                 // for subscriptions we want to keep the original
434                 // message, so that we have a reference to the response listener
435                 if (!(request instanceof ServiceSubscription<?>)) {
436                     requests.remove(response.getId());
437                 }
438             }
439         }
440
441         switch (response.getType()) {
442             case "response":
443                 if (request == null) {
444                     logger.debug("No matching request found for response message: {}", messageToLog);
445                     break;
446                 }
447                 if (payload == null) {
448                     logger.debug("No payload in response message: {}", messageToLog);
449                     break;
450                 }
451                 try {
452                     request.processResponse(jsonPayload);
453                 } catch (RuntimeException ex) {
454                     // An uncaught runtime exception in @OnWebSocketMessage annotated method will cause the web socket
455                     // implementation to call @OnWebSocketError callback in which we would reset the connection.
456                     // Users have the ability to create miss-configurations in which IllegalArgumentException could be
457                     // thrown
458                     logger.warn("Error while processing message: {} - in response to request: {} - Error Message: {}",
459                             messageToLog, request, ex.getMessage());
460                 }
461                 break;
462             case "error":
463                 logger.debug("Error: {}", messageToLog);
464
465                 if (request == null) {
466                     logger.warn("No matching request found for error message: {}", messageToLog);
467                     break;
468                 }
469                 if (payload == null) {
470                     logger.warn("No payload in error message: {}", messageToLog);
471                     break;
472                 }
473                 try {
474                     request.processError(response.getError());
475                 } catch (RuntimeException ex) {
476                     // An uncaught runtime exception in @OnWebSocketMessage annotated method will cause the web socket
477                     // implementation to call @OnWebSocketError callback in which we would reset the connection.
478                     // Users have the ability to create miss-configurations in which IllegalArgumentException could be
479                     // thrown
480                     logger.warn("Error while processing error: {} - in response to request: {} - Error Message: {}",
481                             messageToLog, request, ex.getMessage());
482                 }
483                 break;
484             case "hello":
485                 if (state != State.CONNECTING) {
486                     logger.debug("Skipping response {}, not in CONNECTING state, state was {}", messageToLog, state);
487                     break;
488                 }
489                 if (jsonPayload == null) {
490                     logger.warn("No payload in error message: {}", messageToLog);
491                     break;
492                 }
493                 Map<String, String> map = new HashMap<>();
494                 map.put(PROPERTY_DEVICE_OS, jsonPayload.get("deviceOS").getAsString());
495                 map.put(PROPERTY_DEVICE_OS_VERSION, jsonPayload.get("deviceOSVersion").getAsString());
496                 map.put(PROPERTY_DEVICE_OS_RELEASE_VERSION, jsonPayload.get("deviceOSReleaseVersion").getAsString());
497                 map.put(PROPERTY_LAST_CONNECTED, Instant.now().toString());
498                 config.storeProperties(map);
499                 sendRegister();
500                 break;
501             case "registered":
502                 if (state != State.REGISTERING) {
503                     logger.debug("Skipping response {}, not in REGISTERING state, state was {}", messageToLog, state);
504                     break;
505                 }
506                 if (jsonPayload == null) {
507                     logger.warn("No payload in registered message: {}", messageToLog);
508                     break;
509                 }
510                 this.requests.remove(response.getId());
511                 config.storeKey(jsonPayload.get("client-key").getAsString());
512                 setState(State.REGISTERED);
513                 break;
514         }
515     }
516
517     public interface WebOSTVSocketListener {
518
519         public void onStateChanged(State state);
520
521         public void onError(String errorMessage);
522     }
523
524     public ServiceSubscription<Boolean> subscribeMute(ResponseListener<Boolean> listener) {
525         ServiceSubscription<Boolean> request = new ServiceSubscription<>(MUTE, null,
526                 (jsonObj) -> jsonObj.get("mute").getAsBoolean(), listener);
527         sendCommand(request);
528         return request;
529     }
530
531     public ServiceCommand<Boolean> getMute(ResponseListener<Boolean> listener) {
532         ServiceCommand<Boolean> request = new ServiceCommand<>(MUTE, null,
533                 (jsonObj) -> jsonObj.get("mute").getAsBoolean(), listener);
534         sendCommand(request);
535         return request;
536     }
537
538     private Float volumeFromResponse(JsonObject jsonObj) {
539         JsonObject parent = jsonObj.has("volumeStatus") ? jsonObj.getAsJsonObject("volumeStatus") : jsonObj;
540         return parent.get("volume").getAsInt() >= 0 ? (float) (parent.get("volume").getAsInt() / 100.0) : Float.NaN;
541     }
542
543     public ServiceSubscription<Float> subscribeVolume(ResponseListener<Float> listener) {
544         ServiceSubscription<Float> request = new ServiceSubscription<>(VOLUME, null, this::volumeFromResponse,
545                 listener);
546         sendCommand(request);
547         return request;
548     }
549
550     public ServiceCommand<Float> getVolume(ResponseListener<Float> listener) {
551         ServiceCommand<Float> request = new ServiceCommand<>(VOLUME, null, this::volumeFromResponse, listener);
552         sendCommand(request);
553         return request;
554     }
555
556     public void setMute(boolean isMute, ResponseListener<CommandConfirmation> listener) {
557         String uri = "ssap://audio/setMute";
558         JsonObject payload = new JsonObject();
559         payload.addProperty("mute", isMute);
560
561         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, payload,
562                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
563         sendCommand(request);
564     }
565
566     public void setVolume(float volume, ResponseListener<CommandConfirmation> listener) {
567         String uri = "ssap://audio/setVolume";
568         JsonObject payload = new JsonObject();
569         int intVolume = Math.round(volume * 100.0f);
570         payload.addProperty("volume", intVolume);
571         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, payload,
572                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
573         sendCommand(request);
574     }
575
576     public void volumeUp(ResponseListener<CommandConfirmation> listener) {
577         String uri = "ssap://audio/volumeUp";
578         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
579                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
580         sendCommand(request);
581     }
582
583     public void volumeDown(ResponseListener<CommandConfirmation> listener) {
584         String uri = "ssap://audio/volumeDown";
585         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
586                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
587         sendCommand(request);
588     }
589
590     public ServiceSubscription<ChannelInfo> subscribeCurrentChannel(ResponseListener<ChannelInfo> listener) {
591         ServiceSubscription<ChannelInfo> request = new ServiceSubscription<>(CHANNEL, null,
592                 jsonObj -> GSON.fromJson(jsonObj, ChannelInfo.class), listener);
593         sendCommand(request);
594
595         return request;
596     }
597
598     public ServiceCommand<ChannelInfo> getCurrentChannel(ResponseListener<ChannelInfo> listener) {
599         ServiceCommand<ChannelInfo> request = new ServiceCommand<>(CHANNEL, null,
600                 jsonObj -> GSON.fromJson(jsonObj, ChannelInfo.class), listener);
601         sendCommand(request);
602
603         return request;
604     }
605
606     public void setChannel(ChannelInfo channelInfo, ResponseListener<CommandConfirmation> listener) {
607         JsonObject payload = new JsonObject();
608         if (channelInfo.getId() != null) {
609             payload.addProperty("channelId", channelInfo.getId());
610         }
611         if (channelInfo.getChannelNumber() != null) {
612             payload.addProperty("channelNumber", channelInfo.getChannelNumber());
613         }
614         setChannel(payload, listener);
615     }
616
617     private void setChannel(JsonObject payload, ResponseListener<CommandConfirmation> listener) {
618         String uri = "ssap://tv/openChannel";
619         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, payload,
620                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
621         sendCommand(request);
622     }
623
624     public void channelUp(ResponseListener<CommandConfirmation> listener) {
625         String uri = "ssap://tv/channelUp";
626         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
627                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
628         sendCommand(request);
629     }
630
631     public void channelDown(ResponseListener<CommandConfirmation> listener) {
632         String uri = "ssap://tv/channelDown";
633         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
634                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
635         sendCommand(request);
636     }
637
638     public void getChannelList(ResponseListener<List<ChannelInfo>> listener) {
639         ServiceCommand<List<ChannelInfo>> request = new ServiceCommand<>(CHANNEL_LIST, null,
640                 jsonObj -> GSON.fromJson(jsonObj.get("channelList"), new TypeToken<ArrayList<ChannelInfo>>() {
641                 }.getType()), listener);
642         sendCommand(request);
643     }
644
645     // TOAST
646
647     public void showToast(String message, ResponseListener<CommandConfirmation> listener) {
648         showToast(message, null, null, listener);
649     }
650
651     public void showToast(String message, @Nullable String iconData, @Nullable String iconExtension,
652             ResponseListener<CommandConfirmation> listener) {
653         JsonObject payload = new JsonObject();
654         payload.addProperty("message", message);
655
656         if (iconData != null && iconExtension != null) {
657             payload.addProperty("iconData", iconData);
658             payload.addProperty("iconExtension", iconExtension);
659         }
660
661         sendToast(payload, listener);
662     }
663
664     private void sendToast(JsonObject payload, ResponseListener<CommandConfirmation> listener) {
665         String uri = "ssap://system.notifications/createToast";
666         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, payload,
667                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
668         sendCommand(request);
669     }
670
671     // POWER
672     public void powerOff(ResponseListener<CommandConfirmation> listener) {
673         String uri = "ssap://system/turnOff";
674
675         ResponseListener<CommandConfirmation> interceptor = new ResponseListener<>() {
676
677             @Override
678             public void onSuccess(CommandConfirmation confirmation) {
679                 if (confirmation.getReturnValue()) {
680                     disconnecting();
681                 }
682                 listener.onSuccess(confirmation);
683             }
684
685             @Override
686             public void onError(String message) {
687                 listener.onError(message);
688             }
689         };
690         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
691                 x -> GSON.fromJson(x, CommandConfirmation.class), interceptor);
692         sendCommand(request);
693     }
694
695     // MEDIA CONTROL
696     public void play(ResponseListener<CommandConfirmation> listener) {
697         String uri = "ssap://media.controls/play";
698         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
699                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
700         sendCommand(request);
701     }
702
703     public void pause(ResponseListener<CommandConfirmation> listener) {
704         String uri = "ssap://media.controls/pause";
705         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
706                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
707         sendCommand(request);
708     }
709
710     public void stop(ResponseListener<CommandConfirmation> listener) {
711         String uri = "ssap://media.controls/stop";
712         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
713                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
714         sendCommand(request);
715     }
716
717     public void rewind(ResponseListener<CommandConfirmation> listener) {
718         String uri = "ssap://media.controls/rewind";
719         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
720                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
721         sendCommand(request);
722     }
723
724     public void fastForward(ResponseListener<CommandConfirmation> listener) {
725         String uri = "ssap://media.controls/fastForward";
726         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
727                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
728         sendCommand(request);
729     }
730
731     // APPS
732
733     public void getAppList(final ResponseListener<List<AppInfo>> listener) {
734         String uri = "ssap://com.webos.applicationManager/listApps";
735
736         ServiceCommand<List<AppInfo>> request = new ServiceCommand<>(uri, null,
737                 jsonObj -> GSON.fromJson(jsonObj.get("apps"), new TypeToken<ArrayList<AppInfo>>() {
738                 }.getType()), listener);
739
740         sendCommand(request);
741     }
742
743     public void launchAppWithInfo(AppInfo appInfo, ResponseListener<LaunchSession> listener) {
744         launchAppWithInfo(appInfo, null, listener);
745     }
746
747     public void launchAppWithInfo(final AppInfo appInfo, @Nullable JsonObject params,
748             final ResponseListener<LaunchSession> listener) {
749         String uri = "ssap://system.launcher/launch";
750         JsonObject payload = new JsonObject();
751
752         final String appId = appInfo.getId();
753
754         String contentId = null;
755
756         if (params != null) {
757             contentId = params.get("contentId").getAsString();
758         }
759
760         payload.addProperty("id", appId);
761
762         if (contentId != null) {
763             payload.addProperty("contentId", contentId);
764         }
765
766         if (params != null) {
767             payload.add("params", params);
768         }
769
770         ServiceCommand<LaunchSession> request = new ServiceCommand<>(uri, payload, obj -> {
771             LaunchSession launchSession = new LaunchSession();
772             launchSession.setService(this);
773             launchSession.setAppId(appId); // note that response uses id to mean appId
774             if (obj.has("sessionId")) {
775                 launchSession.setSessionId(obj.get("sessionId").getAsString());
776                 launchSession.setSessionType(LaunchSessionType.App);
777             } else {
778                 launchSession.setSessionType(LaunchSessionType.Unknown);
779             }
780             return launchSession;
781         }, listener);
782         sendCommand(request);
783     }
784
785     public void launchBrowser(String url, final ResponseListener<LaunchSession> listener) {
786         String uri = "ssap://system.launcher/open";
787         JsonObject payload = new JsonObject();
788         payload.addProperty("target", url);
789
790         ServiceCommand<LaunchSession> request = new ServiceCommand<>(uri, payload, obj -> {
791             LaunchSession launchSession = new LaunchSession();
792             launchSession.setService(this);
793             launchSession.setAppId(obj.get("id").getAsString()); // note that response uses id to mean appId
794             if (obj.has("sessionId")) {
795                 launchSession.setSessionId(obj.get("sessionId").getAsString());
796                 launchSession.setSessionType(LaunchSessionType.App);
797             } else {
798                 launchSession.setSessionType(LaunchSessionType.Unknown);
799             }
800             return launchSession;
801         }, listener);
802         sendCommand(request);
803     }
804
805     public void closeLaunchSession(LaunchSession launchSession, ResponseListener<CommandConfirmation> listener) {
806         LGWebOSTVSocket service = launchSession.getService();
807
808         switch (launchSession.getSessionType()) {
809             case App:
810             case ExternalInputPicker:
811                 service.closeApp(launchSession, listener);
812                 break;
813
814             /*
815              * If we want to extend support for MediaPlayer or WebAppLauncher at some point, this is how it was handeled
816              * in connectsdk:
817              *
818              * case Media:
819              * if (service instanceof MediaPlayer) {
820              * ((MediaPlayer) service).closeMedia(launchSession, listener);
821              * }
822              * break;
823              *
824              *
825              * case WebApp:
826              * if (service instanceof WebAppLauncher) {
827              * ((WebAppLauncher) service).closeWebApp(launchSession, listener);
828              * }
829              * break;
830              * case Unknown:
831              */
832             default:
833                 listener.onError("This DeviceService does not know ho to close this LaunchSession");
834                 break;
835         }
836     }
837
838     public void closeApp(LaunchSession launchSession, ResponseListener<CommandConfirmation> listener) {
839         String uri = "ssap://system.launcher/close";
840
841         JsonObject payload = new JsonObject();
842         payload.addProperty("id", launchSession.getAppId());
843         payload.addProperty("sessionId", launchSession.getSessionId());
844
845         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, payload,
846                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
847         launchSession.getService().sendCommand(request);
848     }
849
850     public ServiceSubscription<AppInfo> subscribeRunningApp(ResponseListener<AppInfo> listener) {
851         ResponseListener<AppInfo> interceptor = new ResponseListener<>() {
852
853             @Override
854             public void onSuccess(AppInfo appInfo) {
855                 if (appInfo.getId().isEmpty()) {
856                     scheduleDisconectingJob();
857                 } else {
858                     stopDisconnectingJob();
859                     if (state == State.DISCONNECTING) {
860                         setState(State.REGISTERED);
861                     }
862                 }
863                 listener.onSuccess(appInfo);
864             }
865
866             @Override
867             public void onError(String message) {
868                 listener.onError(message);
869             }
870         };
871         ServiceSubscription<AppInfo> request = new ServiceSubscription<>(FOREGROUND_APP, null,
872                 jsonObj -> GSON.fromJson(jsonObj, AppInfo.class), interceptor);
873         sendCommand(request);
874         return request;
875     }
876
877     public ServiceCommand<AppInfo> getRunningApp(ResponseListener<AppInfo> listener) {
878         ServiceCommand<AppInfo> request = new ServiceCommand<>(FOREGROUND_APP, null,
879                 jsonObj -> GSON.fromJson(jsonObj, AppInfo.class), listener);
880         sendCommand(request);
881         return request;
882     }
883
884     // KEYBOARD
885
886     public ServiceSubscription<TextInputStatusInfo> subscribeTextInputStatus(
887             ResponseListener<TextInputStatusInfo> listener) {
888         return keyboardInput.connect(listener);
889     }
890
891     public void sendText(String input) {
892         keyboardInput.sendText(input);
893     }
894
895     public void sendEnter() {
896         keyboardInput.sendEnter();
897     }
898
899     public void sendDelete() {
900         keyboardInput.sendDel();
901     }
902
903     // MOUSE
904
905     public void executeMouse(Consumer<LGWebOSTVMouseSocket> onConnected) {
906         LGWebOSTVMouseSocket mouseSocket = new LGWebOSTVMouseSocket(this.client);
907         mouseSocket.setListener(new WebOSTVMouseSocketListener() {
908
909             @Override
910             public void onStateChanged(LGWebOSTVMouseSocket.State oldState, LGWebOSTVMouseSocket.State newState) {
911                 switch (newState) {
912                     case CONNECTED:
913                         onConnected.accept(mouseSocket);
914                         mouseSocket.disconnect();
915                         break;
916                     default:
917                         break;
918                 }
919             }
920
921             @Override
922             public void onError(String errorMessage) {
923                 logger.debug("Error in communication with Mouse Socket: {}", errorMessage);
924             }
925         });
926
927         String uri = "ssap://com.webos.service.networkinput/getPointerInputSocket";
928
929         ResponseListener<JsonObject> listener = new ResponseListener<>() {
930
931             @Override
932             public void onSuccess(@Nullable JsonObject jsonObj) {
933                 if (jsonObj != null) {
934                     String socketPath = jsonObj.get("socketPath").getAsString();
935                     if (protocol == Protocol.WEB_SOCKET) {
936                         socketPath = socketPath
937                                 .replace(Protocol.WEB_SOCKET_SECURE.name + ":", Protocol.WEB_SOCKET.name + ":")
938                                 .replace(":" + Protocol.WEB_SOCKET_SECURE.port + "/",
939                                         ":" + Protocol.WEB_SOCKET.port + "/");
940                     }
941                     try {
942                         mouseSocket.connect(new URI(socketPath));
943                     } catch (URISyntaxException e) {
944                         logger.warn("Connect mouse error: {}", e.getMessage());
945                     }
946                 }
947             }
948
949             @Override
950             public void onError(String error) {
951                 logger.warn("Connect mouse error: {}", error);
952             }
953         };
954
955         ServiceCommand<JsonObject> request = new ServiceCommand<>(uri, null, x -> x, listener);
956         sendCommand(request);
957     }
958
959     // Simulate Remote Control Button press
960
961     public void sendRCButton(String rcButton, ResponseListener<CommandConfirmation> listener) {
962         executeMouse(s -> s.button(rcButton));
963     }
964
965     public interface ConfigProvider {
966         void storeKey(String key);
967
968         void storeProperties(Map<String, String> properties);
969
970         String getKey();
971     }
972 }