2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.chromecast.internal.handler;
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;
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;
49 import su.litvak.chromecast.api.v2.ChromeCast;
52 * The {@link ChromecastHandler} is responsible for handling commands, which are sent to one of the channels. It
53 * furthermore implements {@link AudioSink} support.
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.
61 public class ChromecastHandler extends BaseThingHandler implements AudioSink {
62 private final Logger logger = LoggerFactory.getLogger(ChromecastHandler.class);
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);
67 private final AudioHTTPServer audioHTTPServer;
68 private final @Nullable String callbackUrl;
71 * The actual implementation. A new one is created each time #initialize is called.
73 private @Nullable Coordinator coordinator;
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
82 public ChromecastHandler(final Thing thing, AudioHTTPServer audioHTTPServer, @Nullable String callbackUrl) {
84 this.audioHTTPServer = audioHTTPServer;
85 this.callbackUrl = callbackUrl;
89 public void initialize() {
90 ChromecastConfig config = getConfigAs(ChromecastConfig.class);
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.");
99 updateStatus(ThingStatus.UNKNOWN);
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;
108 if (localCoordinator == null) {
109 ChromeCast chromecast = new ChromeCast(ipAddress, config.port);
110 localCoordinator = new Coordinator(this, thing, chromecast, config.refreshRate, audioHTTPServer,
112 coordinator = localCoordinator;
114 scheduler.submit(() -> {
115 Coordinator c = coordinator;
124 public void dispose() {
125 Coordinator localCoordinator = coordinator;
126 if (localCoordinator != null) {
127 localCoordinator.destroy();
133 public void handleCommand(final ChannelUID channelUID, final Command command) {
134 Coordinator localCoordinator = coordinator;
135 if (localCoordinator != null) {
136 localCoordinator.commander.handleCommand(channelUID, command);
138 logger.debug("Cannot handle command. No coordinator has been initialized");
142 @Override // Just exposing this for ChromecastStatusUpdater.
143 public void updateState(String channelId, State state) {
144 super.updateState(channelId, state);
147 @Override // Just exposing this for ChromecastStatusUpdater.
148 public void updateState(ChannelUID channelUID, State state) {
149 super.updateState(channelUID, state);
152 @Override // Just exposing this for ChromecastStatusUpdater.
153 public void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, @Nullable String description) {
154 super.updateStatus(status, statusDetail, description);
157 @Override // Just exposing this for ChromecastStatusUpdater.
158 public boolean isLinked(String channelId) {
159 return super.isLinked(channelId);
162 @Override // Just exposing this for ChromecastStatusUpdater.
163 public boolean isLinked(ChannelUID channelUID) {
164 return super.isLinked(channelUID);
168 public String getId() {
169 return thing.getUID().toString();
173 public @Nullable String getLabel(@Nullable Locale locale) {
174 return thing.getLabel();
178 public Set<AudioFormat> getSupportedFormats() {
179 return SUPPORTED_FORMATS;
183 public Set<Class<? extends AudioStream>> getSupportedStreams() {
184 return SUPPORTED_STREAMS;
188 public void process(@Nullable AudioStream audioStream)
189 throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
190 Coordinator localCoordinator = coordinator;
191 if (localCoordinator != null) {
192 localCoordinator.audioSink.process(audioStream);
194 logger.debug("Cannot process audioStream. No coordinator has been initialized.");
199 public PercentType getVolume() throws IOException {
200 Coordinator localCoordinator = coordinator;
201 if (localCoordinator != null) {
202 return localCoordinator.statusUpdater.getVolume();
204 throw new IOException("Cannot get volume. No coordinator has been initialized.");
209 public void setVolume(PercentType percentType) throws IOException {
210 Coordinator localCoordinator = coordinator;
211 if (localCoordinator != null) {
212 localCoordinator.commander.handleVolume(percentType);
214 throw new IOException("Cannot set volume. No coordinator has been initialized.");
219 public Collection<Class<? extends ThingHandlerService>> getServices() {
220 return List.of(ChromecastActions.class);
223 public boolean playURL(String url, @Nullable String mediaType) {
224 Coordinator localCoordinator = coordinator;
225 if (localCoordinator != null) {
226 localCoordinator.commander.playMedia(null, url, mediaType);
232 private static class Coordinator {
233 private final Logger logger = LoggerFactory.getLogger(Coordinator.class);
235 private static final long CONNECT_DELAY = 10;
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;
245 * used internally to represent the connection state
247 private enum ConnectionState {
255 private ConnectionState connectionState = ConnectionState.UNKNOWN;
257 private Coordinator(ChromecastHandler handler, Thing thing, ChromeCast chromeCast, long refreshRate,
258 AudioHTTPServer audioHttpServer, @Nullable String callbackURL) {
259 this.chromeCast = chromeCast;
261 this.scheduler = new ChromecastScheduler(handler.scheduler, CONNECT_DELAY, this::connect, refreshRate,
263 this.statusUpdater = new ChromecastStatusUpdater(thing, handler);
265 this.commander = new ChromecastCommander(chromeCast, scheduler, statusUpdater);
266 this.eventReceiver = new ChromecastEventReceiver(scheduler, statusUpdater);
267 this.audioSink = new ChromecastAudioSink(commander, audioHttpServer, callbackURL);
271 if (connectionState == ConnectionState.CONNECTED) {
272 logger.debug("Already connected");
274 } else if (connectionState == ConnectionState.CONNECTING) {
275 logger.debug("Already connecting");
277 } else if (connectionState == ConnectionState.DISCONNECTING) {
278 logger.warn("Trying to re-connect while still disconnecting");
281 connectionState = ConnectionState.CONNECTING;
283 chromeCast.registerListener(eventReceiver);
284 chromeCast.registerConnectionListener(eventReceiver);
290 connectionState = ConnectionState.DISCONNECTING;
292 chromeCast.unregisterConnectionListener(eventReceiver);
293 chromeCast.unregisterListener(eventReceiver);
298 chromeCast.disconnect();
300 connectionState = ConnectionState.DISCONNECTED;
301 } catch (final IOException e) {
302 logger.debug("Disconnect failed: {}", e.getMessage());
303 connectionState = ConnectionState.UNKNOWN;
307 private void connect() {
309 chromeCast.connect();
311 statusUpdater.updateMediaStatus(null);
312 statusUpdater.updateStatus(ThingStatus.ONLINE);
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,
319 scheduler.scheduleConnect();
323 private void refresh() {
324 commander.handleRefresh();