]> git.basschouten.com Git - openhab-addons.git/blob
adcfbb5e705bac6ccb0e53fd12e868b49243e75c
[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.chromecast.internal.handler;
14
15 import java.io.IOException;
16 import java.security.GeneralSecurityException;
17 import java.util.Collection;
18 import java.util.List;
19 import java.util.Locale;
20 import java.util.Set;
21
22 import org.eclipse.jdt.annotation.NonNullByDefault;
23 import org.eclipse.jdt.annotation.Nullable;
24 import org.openhab.binding.chromecast.internal.ChromecastAudioSink;
25 import org.openhab.binding.chromecast.internal.ChromecastCommander;
26 import org.openhab.binding.chromecast.internal.ChromecastEventReceiver;
27 import org.openhab.binding.chromecast.internal.ChromecastScheduler;
28 import org.openhab.binding.chromecast.internal.ChromecastStatusUpdater;
29 import org.openhab.binding.chromecast.internal.action.ChromecastActions;
30 import org.openhab.binding.chromecast.internal.config.ChromecastConfig;
31 import org.openhab.core.audio.AudioFormat;
32 import org.openhab.core.audio.AudioHTTPServer;
33 import org.openhab.core.audio.AudioSink;
34 import org.openhab.core.audio.AudioStream;
35 import org.openhab.core.audio.UnsupportedAudioFormatException;
36 import org.openhab.core.audio.UnsupportedAudioStreamException;
37 import org.openhab.core.library.types.PercentType;
38 import org.openhab.core.thing.ChannelUID;
39 import org.openhab.core.thing.Thing;
40 import org.openhab.core.thing.ThingStatus;
41 import org.openhab.core.thing.ThingStatusDetail;
42 import org.openhab.core.thing.binding.BaseThingHandler;
43 import org.openhab.core.thing.binding.ThingHandlerService;
44 import org.openhab.core.types.Command;
45 import org.openhab.core.types.State;
46 import org.slf4j.Logger;
47 import org.slf4j.LoggerFactory;
48
49 import su.litvak.chromecast.api.v2.ChromeCast;
50
51 /**
52  * The {@link ChromecastHandler} is responsible for handling commands, which are sent to one of the channels. It
53  * furthermore implements {@link AudioSink} support.
54  *
55  * @author Markus Rathgeb, Kai Kreuzer - Initial contribution
56  * @author Daniel Walters - Online status fix, handle playuri channel and refactor play media code
57  * @author Jason Holmes - Media Status. Refactor the monolith into separate classes.
58  * @author Scott Hanson - Added Actions.
59  */
60 @NonNullByDefault
61 public class ChromecastHandler extends BaseThingHandler implements AudioSink {
62     private final Logger logger = LoggerFactory.getLogger(ChromecastHandler.class);
63
64     private static final Set<AudioFormat> SUPPORTED_FORMATS = Set.of(AudioFormat.MP3, AudioFormat.WAV);
65     private static final Set<Class<? extends AudioStream>> SUPPORTED_STREAMS = Set.of(AudioStream.class);
66
67     private final AudioHTTPServer audioHTTPServer;
68     private final @Nullable String callbackUrl;
69
70     /**
71      * The actual implementation. A new one is created each time #initialize is called.
72      */
73     private @Nullable Coordinator coordinator;
74
75     /**
76      * Constructor.
77      *
78      * @param thing the thing the coordinator should be created for
79      * @param audioHTTPServer server for hosting audio streams
80      * @param callbackUrl url to be used to tell the Chromecast which host to call for audio urls
81      */
82     public ChromecastHandler(final Thing thing, AudioHTTPServer audioHTTPServer, @Nullable String callbackUrl) {
83         super(thing);
84         this.audioHTTPServer = audioHTTPServer;
85         this.callbackUrl = callbackUrl;
86     }
87
88     @Override
89     public void initialize() {
90         ChromecastConfig config = getConfigAs(ChromecastConfig.class);
91
92         final String ipAddress = config.ipAddress;
93         if (ipAddress == null || ipAddress.isBlank()) {
94             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
95                     "Cannot connect to Chromecast. IP address is not valid or missing.");
96             return;
97         }
98
99         updateStatus(ThingStatus.UNKNOWN);
100
101         Coordinator localCoordinator = coordinator;
102         if (localCoordinator != null && (!localCoordinator.chromeCast.getAddress().equals(ipAddress)
103                 || (localCoordinator.chromeCast.getPort() != config.port))) {
104             localCoordinator.destroy();
105             localCoordinator = coordinator = null;
106         }
107
108         if (localCoordinator == null) {
109             ChromeCast chromecast = new ChromeCast(ipAddress, config.port);
110             localCoordinator = new Coordinator(this, thing, chromecast, config.refreshRate, audioHTTPServer,
111                     callbackUrl);
112             coordinator = localCoordinator;
113
114             scheduler.submit(() -> {
115                 Coordinator c = coordinator;
116                 if (c != null) {
117                     c.initialize();
118                 }
119             });
120         }
121     }
122
123     @Override
124     public void dispose() {
125         Coordinator localCoordinator = coordinator;
126         if (localCoordinator != null) {
127             localCoordinator.destroy();
128             coordinator = null;
129         }
130     }
131
132     @Override
133     public void handleCommand(final ChannelUID channelUID, final Command command) {
134         Coordinator localCoordinator = coordinator;
135         if (localCoordinator != null) {
136             localCoordinator.commander.handleCommand(channelUID, command);
137         } else {
138             logger.debug("Cannot handle command. No coordinator has been initialized");
139         }
140     }
141
142     @Override // Just exposing this for ChromecastStatusUpdater.
143     public void updateState(String channelId, State state) {
144         super.updateState(channelId, state);
145     }
146
147     @Override // Just exposing this for ChromecastStatusUpdater.
148     public void updateState(ChannelUID channelUID, State state) {
149         super.updateState(channelUID, state);
150     }
151
152     @Override // Just exposing this for ChromecastStatusUpdater.
153     public void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, @Nullable String description) {
154         super.updateStatus(status, statusDetail, description);
155     }
156
157     @Override // Just exposing this for ChromecastStatusUpdater.
158     public boolean isLinked(String channelId) {
159         return super.isLinked(channelId);
160     }
161
162     @Override // Just exposing this for ChromecastStatusUpdater.
163     public boolean isLinked(ChannelUID channelUID) {
164         return super.isLinked(channelUID);
165     }
166
167     @Override
168     public String getId() {
169         return thing.getUID().toString();
170     }
171
172     @Override
173     public @Nullable String getLabel(@Nullable Locale locale) {
174         return thing.getLabel();
175     }
176
177     @Override
178     public Set<AudioFormat> getSupportedFormats() {
179         return SUPPORTED_FORMATS;
180     }
181
182     @Override
183     public Set<Class<? extends AudioStream>> getSupportedStreams() {
184         return SUPPORTED_STREAMS;
185     }
186
187     @Override
188     public void process(@Nullable AudioStream audioStream)
189             throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
190         Coordinator localCoordinator = coordinator;
191         if (localCoordinator != null) {
192             localCoordinator.audioSink.process(audioStream);
193         } else {
194             logger.debug("Cannot process audioStream. No coordinator has been initialized.");
195         }
196     }
197
198     @Override
199     public PercentType getVolume() throws IOException {
200         Coordinator localCoordinator = coordinator;
201         if (localCoordinator != null) {
202             return localCoordinator.statusUpdater.getVolume();
203         } else {
204             throw new IOException("Cannot get volume. No coordinator has been initialized.");
205         }
206     }
207
208     @Override
209     public void setVolume(PercentType percentType) throws IOException {
210         Coordinator localCoordinator = coordinator;
211         if (localCoordinator != null) {
212             localCoordinator.commander.handleVolume(percentType);
213         } else {
214             throw new IOException("Cannot set volume. No coordinator has been initialized.");
215         }
216     }
217
218     @Override
219     public Collection<Class<? extends ThingHandlerService>> getServices() {
220         return List.of(ChromecastActions.class);
221     }
222
223     public boolean playURL(String url, @Nullable String mediaType) {
224         Coordinator localCoordinator = coordinator;
225         if (localCoordinator != null) {
226             localCoordinator.commander.playMedia(null, url, mediaType);
227             return true;
228         }
229         return false;
230     }
231
232     private static class Coordinator {
233         private final Logger logger = LoggerFactory.getLogger(Coordinator.class);
234
235         private static final long CONNECT_DELAY = 10;
236
237         private final ChromeCast chromeCast;
238         private final ChromecastAudioSink audioSink;
239         private final ChromecastCommander commander;
240         private final ChromecastEventReceiver eventReceiver;
241         private final ChromecastStatusUpdater statusUpdater;
242         private final ChromecastScheduler scheduler;
243
244         /**
245          * used internally to represent the connection state
246          */
247         private enum ConnectionState {
248             UNKNOWN,
249             CONNECTING,
250             CONNECTED,
251             DISCONNECTING,
252             DISCONNECTED
253         }
254
255         private ConnectionState connectionState = ConnectionState.UNKNOWN;
256
257         private Coordinator(ChromecastHandler handler, Thing thing, ChromeCast chromeCast, long refreshRate,
258                 AudioHTTPServer audioHttpServer, @Nullable String callbackURL) {
259             this.chromeCast = chromeCast;
260
261             this.scheduler = new ChromecastScheduler(handler.scheduler, CONNECT_DELAY, this::connect, refreshRate,
262                     this::refresh);
263             this.statusUpdater = new ChromecastStatusUpdater(thing, handler);
264
265             this.commander = new ChromecastCommander(chromeCast, scheduler, statusUpdater);
266             this.eventReceiver = new ChromecastEventReceiver(scheduler, statusUpdater);
267             this.audioSink = new ChromecastAudioSink(commander, audioHttpServer, callbackURL);
268         }
269
270         void initialize() {
271             if (connectionState == ConnectionState.CONNECTED) {
272                 logger.debug("Already connected");
273                 return;
274             } else if (connectionState == ConnectionState.CONNECTING) {
275                 logger.debug("Already connecting");
276                 return;
277             } else if (connectionState == ConnectionState.DISCONNECTING) {
278                 logger.warn("Trying to re-connect while still disconnecting");
279                 return;
280             }
281             connectionState = ConnectionState.CONNECTING;
282
283             chromeCast.registerListener(eventReceiver);
284             chromeCast.registerConnectionListener(eventReceiver);
285
286             connect();
287         }
288
289         void destroy() {
290             connectionState = ConnectionState.DISCONNECTING;
291
292             chromeCast.unregisterConnectionListener(eventReceiver);
293             chromeCast.unregisterListener(eventReceiver);
294
295             scheduler.destroy();
296
297             try {
298                 chromeCast.disconnect();
299
300                 connectionState = ConnectionState.DISCONNECTED;
301             } catch (final IOException e) {
302                 logger.debug("Disconnect failed: {}", e.getMessage());
303                 connectionState = ConnectionState.UNKNOWN;
304             }
305         }
306
307         private void connect() {
308             try {
309                 chromeCast.connect();
310
311                 statusUpdater.updateMediaStatus(null);
312                 statusUpdater.updateStatus(ThingStatus.ONLINE);
313
314                 connectionState = ConnectionState.CONNECTED;
315             } catch (final IOException | GeneralSecurityException e) {
316                 logger.debug("Connect failed, trying to reconnect: {}", e.getMessage());
317                 statusUpdater.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
318                         e.getMessage());
319                 scheduler.scheduleConnect();
320             }
321         }
322
323         private void refresh() {
324             commander.handleRefresh();
325         }
326     }
327 }