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