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