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