]> git.basschouten.com Git - openhab-addons.git/blob
a9b39b1d263e5f59cc4c56ff9daa73d457d446bc
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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 package org.openhab.binding.lgwebos.internal.handler;
14
15 import static org.openhab.binding.lgwebos.internal.LGWebOSBindingConstants.*;
16
17 import java.net.InetAddress;
18 import java.net.UnknownHostException;
19 import java.util.Collection;
20 import java.util.Collections;
21 import java.util.HashMap;
22 import java.util.List;
23 import java.util.Map;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
26
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.eclipse.jetty.websocket.client.WebSocketClient;
30 import org.openhab.binding.lgwebos.internal.ChannelHandler;
31 import org.openhab.binding.lgwebos.internal.LGWebOSBindingConstants;
32 import org.openhab.binding.lgwebos.internal.LGWebOSStateDescriptionOptionProvider;
33 import org.openhab.binding.lgwebos.internal.LauncherApplication;
34 import org.openhab.binding.lgwebos.internal.MediaControlPlayer;
35 import org.openhab.binding.lgwebos.internal.MediaControlStop;
36 import org.openhab.binding.lgwebos.internal.PowerControlPower;
37 import org.openhab.binding.lgwebos.internal.RCButtonControl;
38 import org.openhab.binding.lgwebos.internal.TVControlChannel;
39 import org.openhab.binding.lgwebos.internal.ToastControlToast;
40 import org.openhab.binding.lgwebos.internal.VolumeControlMute;
41 import org.openhab.binding.lgwebos.internal.VolumeControlVolume;
42 import org.openhab.binding.lgwebos.internal.WakeOnLanUtility;
43 import org.openhab.binding.lgwebos.internal.action.LGWebOSActions;
44 import org.openhab.binding.lgwebos.internal.handler.LGWebOSTVSocket.WebOSTVSocketListener;
45 import org.openhab.binding.lgwebos.internal.handler.core.AppInfo;
46 import org.openhab.binding.lgwebos.internal.handler.core.ResponseListener;
47 import org.openhab.core.config.core.Configuration;
48 import org.openhab.core.library.types.OnOffType;
49 import org.openhab.core.thing.ChannelUID;
50 import org.openhab.core.thing.Thing;
51 import org.openhab.core.thing.ThingStatus;
52 import org.openhab.core.thing.ThingStatusDetail;
53 import org.openhab.core.thing.binding.BaseThingHandler;
54 import org.openhab.core.thing.binding.ThingHandlerService;
55 import org.openhab.core.types.Command;
56 import org.openhab.core.types.State;
57 import org.openhab.core.types.StateOption;
58 import org.slf4j.Logger;
59 import org.slf4j.LoggerFactory;
60
61 /**
62  * The {@link LGWebOSHandler} is responsible for handling commands, which are
63  * sent to one of the channels.
64  *
65  * @author Sebastian Prehn - initial contribution
66  */
67 @NonNullByDefault
68 public class LGWebOSHandler extends BaseThingHandler
69         implements LGWebOSTVSocket.ConfigProvider, WebOSTVSocketListener, PowerControlPower.ConfigProvider {
70
71     /*
72      * constants for device polling
73      */
74     private static final int RECONNECT_INTERVAL_SECONDS = 10;
75     private static final int RECONNECT_START_UP_DELAY_SECONDS = 0;
76     private static final int CHANNEL_SUBSCRIPTION_DELAY_SECONDS = 1;
77     private static final String APP_ID_LIVETV = "com.webos.app.livetv";
78
79     /*
80      * error messages
81      */
82     private static final String MSG_MISSING_PARAM = "Missing parameter \"host\"";
83
84     private final Logger logger = LoggerFactory.getLogger(LGWebOSHandler.class);
85
86     // ChannelID to CommandHandler Map
87     private final Map<String, ChannelHandler> channelHandlers;
88
89     private final LauncherApplication appLauncher = new LauncherApplication();
90
91     private final WebSocketClient webSocketClient;
92
93     private final LGWebOSStateDescriptionOptionProvider stateDescriptionProvider;
94
95     private @Nullable LGWebOSTVSocket socket;
96
97     private @Nullable ScheduledFuture<?> reconnectJob;
98     private @Nullable ScheduledFuture<?> keepAliveJob;
99     private @Nullable ScheduledFuture<?> channelSubscriptionJob;
100
101     private @Nullable LGWebOSConfiguration config;
102
103     public LGWebOSHandler(Thing thing, WebSocketClient webSocketClient,
104             LGWebOSStateDescriptionOptionProvider stateDescriptionProvider) {
105         super(thing);
106         this.webSocketClient = webSocketClient;
107         this.stateDescriptionProvider = stateDescriptionProvider;
108
109         Map<String, ChannelHandler> handlers = new HashMap<>();
110         handlers.put(CHANNEL_VOLUME, new VolumeControlVolume());
111         handlers.put(CHANNEL_POWER, new PowerControlPower(this, scheduler));
112         handlers.put(CHANNEL_MUTE, new VolumeControlMute());
113         handlers.put(CHANNEL_CHANNEL, new TVControlChannel());
114         handlers.put(CHANNEL_APP_LAUNCHER, appLauncher);
115         handlers.put(CHANNEL_MEDIA_STOP, new MediaControlStop());
116         handlers.put(CHANNEL_TOAST, new ToastControlToast());
117         handlers.put(CHANNEL_MEDIA_PLAYER, new MediaControlPlayer());
118         handlers.put(CHANNEL_RCBUTTON, new RCButtonControl());
119         channelHandlers = Collections.unmodifiableMap(handlers);
120     }
121
122     private LGWebOSConfiguration getLGWebOSConfig() {
123         LGWebOSConfiguration c = config;
124         if (c == null) {
125             c = getConfigAs(LGWebOSConfiguration.class);
126             config = c;
127         }
128         return c;
129     }
130
131     @Override
132     public void initialize() {
133         logger.debug("Initializing handler for thing {}", getThing().getUID());
134         LGWebOSConfiguration c = getLGWebOSConfig();
135         logger.trace("Handler initialized with config {}", c);
136         String host = c.getHost();
137         if (host.isEmpty()) {
138             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, MSG_MISSING_PARAM);
139             return;
140         }
141
142         LGWebOSTVSocket s = new LGWebOSTVSocket(webSocketClient, this, host, c.getPort(), scheduler);
143         s.setListener(this);
144         socket = s;
145
146         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "TV is off");
147
148         startReconnectJob();
149     }
150
151     @Override
152     public void dispose() {
153         logger.debug("Disposing handler for thing {}", getThing().getUID());
154         stopKeepAliveJob();
155         stopReconnectJob();
156         stopChannelSubscriptionJob();
157
158         LGWebOSTVSocket s = socket;
159         if (s != null) {
160             s.setListener(null);
161             s.disconnect();
162         }
163         socket = null;
164         config = null; // ensure config gets actually refreshed during re-initialization
165         super.dispose();
166     }
167
168     private void startReconnectJob() {
169         ScheduledFuture<?> job = reconnectJob;
170         if (job == null || job.isCancelled()) {
171             reconnectJob = scheduler.scheduleWithFixedDelay(() -> {
172                 getSocket().disconnect();
173                 getSocket().connect();
174             }, RECONNECT_START_UP_DELAY_SECONDS, RECONNECT_INTERVAL_SECONDS, TimeUnit.SECONDS);
175         }
176     }
177
178     private void stopReconnectJob() {
179         ScheduledFuture<?> job = reconnectJob;
180         if (job != null && !job.isCancelled()) {
181             job.cancel(true);
182         }
183         reconnectJob = null;
184     }
185
186     /**
187      * Keep alive ensures that the web socket connection is used and does not time out.
188      */
189     private void startKeepAliveJob() {
190         ScheduledFuture<?> job = keepAliveJob;
191         if (job == null || job.isCancelled()) {
192             // half of idle time out setting
193             long keepAliveInterval = this.webSocketClient.getMaxIdleTimeout() / 2;
194
195             // it is irrelevant which service is queried. Only need to send some packets over the wire
196
197             keepAliveJob = scheduler
198                     .scheduleWithFixedDelay(() -> getSocket().getRunningApp(new ResponseListener<AppInfo>() {
199
200                         @Override
201                         public void onSuccess(AppInfo responseObject) {
202                             // ignore - actual response is not relevant here
203                         }
204
205                         @Override
206                         public void onError(String message) {
207                             // ignore
208                         }
209                     }), keepAliveInterval, keepAliveInterval, TimeUnit.MILLISECONDS);
210
211         }
212     }
213
214     private void stopKeepAliveJob() {
215         ScheduledFuture<?> job = keepAliveJob;
216         if (job != null && !job.isCancelled()) {
217             job.cancel(true);
218         }
219         keepAliveJob = null;
220     }
221
222     public LGWebOSTVSocket getSocket() {
223         LGWebOSTVSocket s = this.socket;
224         if (s == null) {
225             throw new IllegalStateException("Component called before it was initialized or already disposed.");
226         }
227         return s;
228     }
229
230     public LauncherApplication getLauncherApplication() {
231         return appLauncher;
232     }
233
234     @Override
235     public void handleCommand(ChannelUID channelUID, Command command) {
236         logger.debug("handleCommand({},{})", channelUID, command);
237         ChannelHandler handler = channelHandlers.get(channelUID.getId());
238         if (handler == null) {
239             logger.warn(
240                     "Unable to handle command {}. No handler found for channel {}. This must not happen. Please report as a bug.",
241                     command, channelUID);
242             return;
243         }
244
245         handler.onReceiveCommand(channelUID.getId(), this, command);
246     }
247
248     @Override
249     public String getMacAddress() {
250         return getLGWebOSConfig().getMacAddress();
251     }
252
253     @Override
254     public String getKey() {
255         return getLGWebOSConfig().getKey();
256     }
257
258     @Override
259     public void storeKey(@Nullable String key) {
260         if (!getKey().equals(key)) {
261             logger.debug("Store new access Key in the thing configuration");
262             // store it current configuration and avoiding complete re-initialization via handleConfigurationUpdate
263             getLGWebOSConfig().key = key;
264
265             // persist the configuration change
266             Configuration configuration = editConfiguration();
267             configuration.put(LGWebOSBindingConstants.CONFIG_KEY, key);
268             updateConfiguration(configuration);
269         }
270     }
271
272     @Override
273     public void storeProperties(Map<String, String> properties) {
274         logger.debug("storeProperties {}", properties);
275         Map<String, String> map = editProperties();
276         map.putAll(properties);
277         updateProperties(map);
278     }
279
280     @Override
281     public void onStateChanged(LGWebOSTVSocket.State state) {
282         switch (state) {
283             case DISCONNECTING:
284                 postUpdate(CHANNEL_POWER, OnOffType.OFF);
285                 break;
286             case DISCONNECTED:
287                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "TV is off");
288                 channelHandlers.forEach((k, v) -> {
289                     v.onDeviceRemoved(k, this);
290                     v.removeAnySubscription(this);
291                 });
292
293                 stopKeepAliveJob();
294                 startReconnectJob();
295                 break;
296             case CONNECTING:
297                 stopReconnectJob();
298                 break;
299             case REGISTERING:
300                 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE,
301                         "Registering - You may need to confirm pairing on TV.");
302                 findMacAddress();
303                 break;
304             case REGISTERED:
305                 startKeepAliveJob();
306                 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "Connected");
307
308                 channelHandlers.forEach((k, v) -> {
309                     // refresh subscriptions except on channel, which can only be subscribe in livetv app. see
310                     // postUpdate method
311                     if (!CHANNEL_CHANNEL.equals(k)) {
312                         v.refreshSubscription(k, this);
313                     }
314                     v.onDeviceReady(k, this);
315                 });
316
317                 break;
318
319         }
320     }
321
322     @Override
323     public void onError(String error) {
324         logger.debug("Connection failed - error: {}", error);
325
326         switch (getSocket().getState()) {
327             case DISCONNECTING:
328             case DISCONNECTED:
329                 break;
330             case CONNECTING:
331             case REGISTERING:
332             case REGISTERED:
333                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Connection Failed: " + error);
334                 break;
335         }
336     }
337
338     public void setOptions(String channelId, List<StateOption> options) {
339         logger.debug("setOptions channelId={} options.size()={}", channelId, options.size());
340         stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), channelId), options);
341     }
342
343     public void postUpdate(String channelId, State state) {
344         if (isLinked(channelId)) {
345             updateState(channelId, state);
346         }
347
348         // channel subscription only works when livetv app is started,
349         // therefore we need to slightly delay the subscription
350         if (CHANNEL_APP_LAUNCHER.equals(channelId)) {
351             if (APP_ID_LIVETV.equals(state.toString())) {
352                 scheduleChannelSubscriptionJob();
353             } else {
354                 stopChannelSubscriptionJob();
355             }
356         }
357     }
358
359     private void scheduleChannelSubscriptionJob() {
360         ScheduledFuture<?> job = channelSubscriptionJob;
361         if (job == null || job.isCancelled()) {
362             logger.debug("Schedule channel subscription job");
363             channelSubscriptionJob = scheduler.schedule(
364                     () -> channelHandlers.get(CHANNEL_CHANNEL).refreshSubscription(CHANNEL_CHANNEL, this),
365                     CHANNEL_SUBSCRIPTION_DELAY_SECONDS, TimeUnit.SECONDS);
366         }
367     }
368
369     private void stopChannelSubscriptionJob() {
370         ScheduledFuture<?> job = channelSubscriptionJob;
371         if (job != null && !job.isCancelled()) {
372             logger.debug("Stop channel subscription job");
373             job.cancel(true);
374         }
375         channelSubscriptionJob = null;
376     }
377
378     @Override
379     public Collection<Class<? extends ThingHandlerService>> getServices() {
380         return Collections.singleton(LGWebOSActions.class);
381     }
382
383     /**
384      * Make a best effort to automatically detect the MAC address of the TV.
385      * If this does not work automatically, users can still set it manually in the Thing config.
386      */
387     private void findMacAddress() {
388         LGWebOSConfiguration c = getLGWebOSConfig();
389         String host = c.getHost();
390         if (!host.isEmpty()) {
391             try {
392                 // validate host, so that no command can be injected
393                 String macAddress = WakeOnLanUtility.getMACAddress(InetAddress.getByName(host).getHostAddress());
394                 if (macAddress != null && !macAddress.equals(c.macAddress)) {
395                     c.macAddress = macAddress;
396                     // persist the configuration change
397                     Configuration configuration = editConfiguration();
398                     configuration.put(LGWebOSBindingConstants.CONFIG_MAC_ADDRESS, macAddress);
399                     updateConfiguration(configuration);
400                 }
401             } catch (UnknownHostException e) {
402                 logger.debug("Unable to determine MAC address: {}", e.getMessage());
403             }
404         }
405     }
406
407     public List<String> reportApplications() {
408         return appLauncher.reportApplications(getThing().getUID());
409     }
410
411     public List<String> reportChannels() {
412         return ((TVControlChannel) channelHandlers.get(CHANNEL_CHANNEL)).reportChannels(getThing().getUID());
413     }
414 }