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