]> git.basschouten.com Git - openhab-addons.git/blob
1f09037c027adc1aada15384c3a33839d209b279
[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         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         final String VOLUME_STATUS = "volumeStatus";
525         final String VOLUME = "volume";
526         JsonObject parent = jsonObj.has(VOLUME_STATUS) ? jsonObj.getAsJsonObject(VOLUME_STATUS) : jsonObj;
527         return parent.get(VOLUME).getAsInt() >= 0 ? (float) (parent.get(VOLUME).getAsInt() / 100.0) : Float.NaN;
528     }
529
530     public ServiceSubscription<Float> subscribeVolume(ResponseListener<Float> listener) {
531         ServiceSubscription<Float> request = new ServiceSubscription<>(VOLUME, null, this::volumeFromResponse,
532                 listener);
533         sendCommand(request);
534         return request;
535     }
536
537     public ServiceCommand<Float> getVolume(ResponseListener<Float> listener) {
538         ServiceCommand<Float> request = new ServiceCommand<>(VOLUME, null, this::volumeFromResponse, listener);
539         sendCommand(request);
540         return request;
541     }
542
543     public void setMute(boolean isMute, ResponseListener<CommandConfirmation> listener) {
544         String uri = "ssap://audio/setMute";
545         JsonObject payload = new JsonObject();
546         payload.addProperty("mute", isMute);
547
548         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, payload,
549                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
550         sendCommand(request);
551     }
552
553     public void setVolume(float volume, ResponseListener<CommandConfirmation> listener) {
554         String uri = "ssap://audio/setVolume";
555         JsonObject payload = new JsonObject();
556         int intVolume = Math.round(volume * 100.0f);
557         payload.addProperty("volume", intVolume);
558         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, payload,
559                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
560         sendCommand(request);
561     }
562
563     public void volumeUp(ResponseListener<CommandConfirmation> listener) {
564         String uri = "ssap://audio/volumeUp";
565         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
566                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
567         sendCommand(request);
568     }
569
570     public void volumeDown(ResponseListener<CommandConfirmation> listener) {
571         String uri = "ssap://audio/volumeDown";
572         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
573                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
574         sendCommand(request);
575     }
576
577     public ServiceSubscription<ChannelInfo> subscribeCurrentChannel(ResponseListener<ChannelInfo> listener) {
578         ServiceSubscription<ChannelInfo> request = new ServiceSubscription<>(CHANNEL, null,
579                 jsonObj -> GSON.fromJson(jsonObj, ChannelInfo.class), listener);
580         sendCommand(request);
581
582         return request;
583     }
584
585     public ServiceCommand<ChannelInfo> getCurrentChannel(ResponseListener<ChannelInfo> listener) {
586         ServiceCommand<ChannelInfo> request = new ServiceCommand<>(CHANNEL, null,
587                 jsonObj -> GSON.fromJson(jsonObj, ChannelInfo.class), listener);
588         sendCommand(request);
589
590         return request;
591     }
592
593     public void setChannel(ChannelInfo channelInfo, ResponseListener<CommandConfirmation> listener) {
594         JsonObject payload = new JsonObject();
595         if (channelInfo.getId() != null) {
596             payload.addProperty("channelId", channelInfo.getId());
597         }
598         if (channelInfo.getChannelNumber() != null) {
599             payload.addProperty("channelNumber", channelInfo.getChannelNumber());
600         }
601         setChannel(payload, listener);
602     }
603
604     private void setChannel(JsonObject payload, ResponseListener<CommandConfirmation> listener) {
605         String uri = "ssap://tv/openChannel";
606         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, payload,
607                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
608         sendCommand(request);
609     }
610
611     public void channelUp(ResponseListener<CommandConfirmation> listener) {
612         String uri = "ssap://tv/channelUp";
613         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
614                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
615         sendCommand(request);
616     }
617
618     public void channelDown(ResponseListener<CommandConfirmation> listener) {
619         String uri = "ssap://tv/channelDown";
620         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
621                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
622         sendCommand(request);
623     }
624
625     public void getChannelList(ResponseListener<List<ChannelInfo>> listener) {
626         ServiceCommand<List<ChannelInfo>> request = new ServiceCommand<>(CHANNEL_LIST, null,
627                 jsonObj -> GSON.fromJson(jsonObj.get("channelList"), new TypeToken<ArrayList<ChannelInfo>>() {
628                 }.getType()), listener);
629         sendCommand(request);
630     }
631
632     // TOAST
633
634     public void showToast(String message, ResponseListener<CommandConfirmation> listener) {
635         showToast(message, null, null, listener);
636     }
637
638     public void showToast(String message, @Nullable String iconData, @Nullable String iconExtension,
639             ResponseListener<CommandConfirmation> listener) {
640         JsonObject payload = new JsonObject();
641         payload.addProperty("message", message);
642
643         if (iconData != null && iconExtension != null) {
644             payload.addProperty("iconData", iconData);
645             payload.addProperty("iconExtension", iconExtension);
646         }
647
648         sendToast(payload, listener);
649     }
650
651     private void sendToast(JsonObject payload, ResponseListener<CommandConfirmation> listener) {
652         String uri = "ssap://system.notifications/createToast";
653         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, payload,
654                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
655         sendCommand(request);
656     }
657
658     // POWER
659     public void powerOff(ResponseListener<CommandConfirmation> listener) {
660         String uri = "ssap://system/turnOff";
661
662         ResponseListener<CommandConfirmation> interceptor = new ResponseListener<CommandConfirmation>() {
663
664             @Override
665             public void onSuccess(CommandConfirmation confirmation) {
666                 if (confirmation.getReturnValue()) {
667                     disconnecting();
668                 }
669                 listener.onSuccess(confirmation);
670             }
671
672             @Override
673             public void onError(String message) {
674                 listener.onError(message);
675             }
676         };
677         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
678                 x -> GSON.fromJson(x, CommandConfirmation.class), interceptor);
679         sendCommand(request);
680     }
681
682     // MEDIA CONTROL
683     public void play(ResponseListener<CommandConfirmation> listener) {
684         String uri = "ssap://media.controls/play";
685         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
686                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
687         sendCommand(request);
688     }
689
690     public void pause(ResponseListener<CommandConfirmation> listener) {
691         String uri = "ssap://media.controls/pause";
692         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
693                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
694         sendCommand(request);
695     }
696
697     public void stop(ResponseListener<CommandConfirmation> listener) {
698         String uri = "ssap://media.controls/stop";
699         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
700                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
701         sendCommand(request);
702     }
703
704     public void rewind(ResponseListener<CommandConfirmation> listener) {
705         String uri = "ssap://media.controls/rewind";
706         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
707                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
708         sendCommand(request);
709     }
710
711     public void fastForward(ResponseListener<CommandConfirmation> listener) {
712         String uri = "ssap://media.controls/fastForward";
713         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, null,
714                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
715         sendCommand(request);
716     }
717
718     // APPS
719
720     public void getAppList(final ResponseListener<List<AppInfo>> listener) {
721         String uri = "ssap://com.webos.applicationManager/listApps";
722
723         ServiceCommand<List<AppInfo>> request = new ServiceCommand<>(uri, null,
724                 jsonObj -> GSON.fromJson(jsonObj.get("apps"), new TypeToken<ArrayList<AppInfo>>() {
725                 }.getType()), listener);
726
727         sendCommand(request);
728     }
729
730     public void launchAppWithInfo(AppInfo appInfo, ResponseListener<LaunchSession> listener) {
731         launchAppWithInfo(appInfo, null, listener);
732     }
733
734     public void launchAppWithInfo(final AppInfo appInfo, @Nullable JsonObject params,
735             final ResponseListener<LaunchSession> listener) {
736         String uri = "ssap://system.launcher/launch";
737         JsonObject payload = new JsonObject();
738
739         final String appId = appInfo.getId();
740
741         String contentId = null;
742
743         if (params != null) {
744             contentId = params.get("contentId").getAsString();
745         }
746
747         payload.addProperty("id", appId);
748
749         if (contentId != null) {
750             payload.addProperty("contentId", contentId);
751         }
752
753         if (params != null) {
754             payload.add("params", params);
755         }
756
757         ServiceCommand<LaunchSession> request = new ServiceCommand<>(uri, payload, obj -> {
758             LaunchSession launchSession = new LaunchSession();
759             launchSession.setService(this);
760             launchSession.setAppId(appId); // note that response uses id to mean appId
761             if (obj.has("sessionId")) {
762                 launchSession.setSessionId(obj.get("sessionId").getAsString());
763                 launchSession.setSessionType(LaunchSessionType.App);
764             } else {
765                 launchSession.setSessionType(LaunchSessionType.Unknown);
766             }
767             return launchSession;
768         }, listener);
769         sendCommand(request);
770     }
771
772     public void launchBrowser(String url, final ResponseListener<LaunchSession> listener) {
773         String uri = "ssap://system.launcher/open";
774         JsonObject payload = new JsonObject();
775         payload.addProperty("target", url);
776
777         ServiceCommand<LaunchSession> request = new ServiceCommand<>(uri, payload, obj -> {
778             LaunchSession launchSession = new LaunchSession();
779             launchSession.setService(this);
780             launchSession.setAppId(obj.get("id").getAsString()); // note that response uses id to mean appId
781             if (obj.has("sessionId")) {
782                 launchSession.setSessionId(obj.get("sessionId").getAsString());
783                 launchSession.setSessionType(LaunchSessionType.App);
784             } else {
785                 launchSession.setSessionType(LaunchSessionType.Unknown);
786             }
787             return launchSession;
788         }, listener);
789         sendCommand(request);
790     }
791
792     public void closeLaunchSession(LaunchSession launchSession, ResponseListener<CommandConfirmation> listener) {
793         LGWebOSTVSocket service = launchSession.getService();
794
795         switch (launchSession.getSessionType()) {
796             case App:
797             case ExternalInputPicker:
798                 service.closeApp(launchSession, listener);
799                 break;
800
801             /*
802              * If we want to extend support for MediaPlayer or WebAppLauncher at some point, this is how it was handeled
803              * in connectsdk:
804              *
805              * case Media:
806              * if (service instanceof MediaPlayer) {
807              * ((MediaPlayer) service).closeMedia(launchSession, listener);
808              * }
809              * break;
810              *
811              *
812              * case WebApp:
813              * if (service instanceof WebAppLauncher) {
814              * ((WebAppLauncher) service).closeWebApp(launchSession, listener);
815              * }
816              * break;
817              * case Unknown:
818              */
819             default:
820                 listener.onError("This DeviceService does not know ho to close this LaunchSession");
821                 break;
822         }
823     }
824
825     public void closeApp(LaunchSession launchSession, ResponseListener<CommandConfirmation> listener) {
826         String uri = "ssap://system.launcher/close";
827
828         JsonObject payload = new JsonObject();
829         payload.addProperty("id", launchSession.getAppId());
830         payload.addProperty("sessionId", launchSession.getSessionId());
831
832         ServiceCommand<CommandConfirmation> request = new ServiceCommand<>(uri, payload,
833                 x -> GSON.fromJson(x, CommandConfirmation.class), listener);
834         launchSession.getService().sendCommand(request);
835     }
836
837     public ServiceSubscription<AppInfo> subscribeRunningApp(ResponseListener<AppInfo> listener) {
838         ResponseListener<AppInfo> interceptor = new ResponseListener<AppInfo>() {
839
840             @Override
841             public void onSuccess(AppInfo appInfo) {
842                 if (appInfo.getId().isEmpty()) {
843                     scheduleDisconectingJob();
844                 } else {
845                     stopDisconnectingJob();
846                     if (state == State.DISCONNECTING) {
847                         setState(State.REGISTERED);
848                     }
849                 }
850                 listener.onSuccess(appInfo);
851             }
852
853             @Override
854             public void onError(String message) {
855                 listener.onError(message);
856             }
857         };
858         ServiceSubscription<AppInfo> request = new ServiceSubscription<>(FOREGROUND_APP, null,
859                 jsonObj -> GSON.fromJson(jsonObj, AppInfo.class), interceptor);
860         sendCommand(request);
861         return request;
862     }
863
864     public ServiceCommand<AppInfo> getRunningApp(ResponseListener<AppInfo> listener) {
865         ServiceCommand<AppInfo> request = new ServiceCommand<>(FOREGROUND_APP, null,
866                 jsonObj -> GSON.fromJson(jsonObj, AppInfo.class), listener);
867         sendCommand(request);
868         return request;
869     }
870
871     // KEYBOARD
872
873     public ServiceSubscription<TextInputStatusInfo> subscribeTextInputStatus(
874             ResponseListener<TextInputStatusInfo> listener) {
875         return keyboardInput.connect(listener);
876     }
877
878     public void sendText(String input) {
879         keyboardInput.sendText(input);
880     }
881
882     public void sendEnter() {
883         keyboardInput.sendEnter();
884     }
885
886     public void sendDelete() {
887         keyboardInput.sendDel();
888     }
889
890     // MOUSE
891
892     public void executeMouse(Consumer<LGWebOSTVMouseSocket> onConnected) {
893         LGWebOSTVMouseSocket mouseSocket = new LGWebOSTVMouseSocket(this.client);
894         mouseSocket.setListener(new WebOSTVMouseSocketListener() {
895
896             @Override
897             public void onStateChanged(LGWebOSTVMouseSocket.State oldState, LGWebOSTVMouseSocket.State newState) {
898                 switch (newState) {
899                     case CONNECTED:
900                         onConnected.accept(mouseSocket);
901                         mouseSocket.disconnect();
902                         break;
903                     default:
904                         break;
905                 }
906             }
907
908             @Override
909             public void onError(String errorMessage) {
910                 logger.debug("Error in communication with Mouse Socket: {}", errorMessage);
911             }
912         });
913
914         String uri = "ssap://com.webos.service.networkinput/getPointerInputSocket";
915
916         ResponseListener<JsonObject> listener = new ResponseListener<JsonObject>() {
917
918             @Override
919             public void onSuccess(@Nullable JsonObject jsonObj) {
920                 if (jsonObj != null) {
921                     String socketPath = jsonObj.get("socketPath").getAsString().replace("wss:", "ws:").replace(":3001/",
922                             ":3000/");
923                     try {
924                         mouseSocket.connect(new URI(socketPath));
925                     } catch (URISyntaxException e) {
926                         logger.warn("Connect mouse error: {}", e.getMessage());
927                     }
928                 }
929             }
930
931             @Override
932             public void onError(String error) {
933                 logger.warn("Connect mouse error: {}", error);
934             }
935         };
936
937         ServiceCommand<JsonObject> request = new ServiceCommand<>(uri, null, x -> x, listener);
938         sendCommand(request);
939     }
940
941     // Simulate Remote Control Button press
942
943     public void sendRCButton(String rcButton, ResponseListener<CommandConfirmation> listener) {
944         executeMouse(s -> s.button(rcButton));
945     }
946
947     public interface ConfigProvider {
948         void storeKey(String key);
949
950         void storeProperties(Map<String, String> properties);
951
952         String getKey();
953     }
954 }