added migrated 2.x add-ons

Signed-off-by: Kai Kreuzer <kai@openhab.org>
This commit is contained in:
Kai Kreuzer
2020-09-21 01:58:32 +02:00
parent bbf1a7fd29
commit 6df6783b60
11662 changed files with 1302875 additions and 11 deletions

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.chromecast-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-chromecast" description="Chromecast Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<feature>openhab-transport-mdns</feature>
<feature dependency="true">openhab.tp-jackson</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.chromecast/${project.version}</bundle>
</feature>
</features>

View File

@@ -0,0 +1,81 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.chromecast.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.audio.AudioFormat;
import org.openhab.core.audio.AudioHTTPServer;
import org.openhab.core.audio.AudioStream;
import org.openhab.core.audio.FixedLengthAudioStream;
import org.openhab.core.audio.URLAudioStream;
import org.openhab.core.audio.UnsupportedAudioFormatException;
import org.openhab.core.library.types.OnOffType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Handles the AudioSink portion of the Chromecast add-on.
*
* @author Jason Holmes - Initial contribution
*/
@NonNullByDefault
public class ChromecastAudioSink {
private final Logger logger = LoggerFactory.getLogger(ChromecastAudioSink.class);
private static final String MIME_TYPE_AUDIO_WAV = "audio/wav";
private static final String MIME_TYPE_AUDIO_MPEG = "audio/mpeg";
private final ChromecastCommander commander;
private final AudioHTTPServer audioHTTPServer;
private final @Nullable String callbackUrl;
public ChromecastAudioSink(ChromecastCommander commander, AudioHTTPServer audioHTTPServer,
@Nullable String callbackUrl) {
this.commander = commander;
this.audioHTTPServer = audioHTTPServer;
this.callbackUrl = callbackUrl;
}
public void process(@Nullable AudioStream audioStream) throws UnsupportedAudioFormatException {
if (audioStream == null) {
// in case the audioStream is null, this should be interpreted as a request to end any currently playing
// stream.
logger.trace("Stop currently playing stream.");
commander.handleStop(OnOffType.ON);
} else {
final String url;
if (audioStream instanceof URLAudioStream) {
// it is an external URL, the speaker can access it itself and play it.
URLAudioStream urlAudioStream = (URLAudioStream) audioStream;
url = urlAudioStream.getURL();
} else {
if (callbackUrl != null) {
// we serve it on our own HTTP server
String relativeUrl;
if (audioStream instanceof FixedLengthAudioStream) {
relativeUrl = audioHTTPServer.serve((FixedLengthAudioStream) audioStream, 10);
} else {
relativeUrl = audioHTTPServer.serve(audioStream);
}
url = callbackUrl + relativeUrl;
} else {
logger.warn("We do not have any callback url, so Chromecast cannot play the audio stream!");
return;
}
}
commander.playMedia("Notification", url,
AudioFormat.MP3.isCompatible(audioStream.getFormat()) ? MIME_TYPE_AUDIO_MPEG : MIME_TYPE_AUDIO_WAV);
}
}
}

View File

@@ -0,0 +1,100 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.chromecast.internal;
import java.util.Collections;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link ChromecastBindingConstants} class defines common constants, which are
* used across the whole binding.
*
* @author Kai Kreuzer - Initial contribution
* @author Jason Holmes - Additional channels
*/
@NonNullByDefault
public class ChromecastBindingConstants {
public static final String BINDING_ID = "chromecast";
public static final String MEDIA_PLAYER = "CC1AD845";
public static final ThingTypeUID THING_TYPE_CHROMECAST = new ThingTypeUID(BINDING_ID, "chromecast");
public static final ThingTypeUID THING_TYPE_AUDIO = new ThingTypeUID(BINDING_ID, "audio");
public static final ThingTypeUID THING_TYPE_AUDIOGROUP = new ThingTypeUID(BINDING_ID, "audiogroup");
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections.unmodifiableSet(
Stream.of(THING_TYPE_AUDIO, THING_TYPE_AUDIOGROUP, THING_TYPE_CHROMECAST).collect(Collectors.toSet()));
// Config Parameters
public static final String HOST = "ipAddress";
public static final String PORT = "port";
public static final String DEVICE_ID = "deviceId";
// Channel IDs
public static final String CHANNEL_CONTROL = "control";
public static final String CHANNEL_STOP = "stop";
public static final String CHANNEL_VOLUME = "volume";
public static final String CHANNEL_MUTE = "mute";
public static final String CHANNEL_PLAY_URI = "playuri";
public static final String CHANNEL_APP_NAME = "appName";
public static final String CHANNEL_APP_ID = "appId";
public static final String CHANNEL_IDLING = "idling";
public static final String CHANNEL_STATUS_TEXT = "statustext";
public static final String CHANNEL_CURRENT_TIME = "currentTime";
public static final String CHANNEL_DURATION = "duration";
public static final String CHANNEL_METADATA_TYPE = "metadataType";
public static final String CHANNEL_ALBUM_ARTIST = "albumArtist";
public static final String CHANNEL_ALBUM_NAME = "albumName";
public static final String CHANNEL_ARTIST = "artist";
public static final String CHANNEL_BROADCAST_DATE = "broadcastDate";
public static final String CHANNEL_COMPOSER = "composer";
public static final String CHANNEL_CREATION_DATE = "creationDate";
public static final String CHANNEL_DISC_NUMBER = "discNumber";
public static final String CHANNEL_EPISODE_NUMBER = "episodeNumber";
public static final String CHANNEL_IMAGE = "image";
public static final String CHANNEL_IMAGE_SRC = "imageSrc";
public static final String CHANNEL_LOCATION_NAME = "locationName";
public static final String CHANNEL_LOCATION = "location";
public static final String CHANNEL_RELEASE_DATE = "releaseDate";
public static final String CHANNEL_SEASON_NUMBER = "seasonNumber";
public static final String CHANNEL_SERIES_TITLE = "seriesTitle";
public static final String CHANNEL_STUDIO = "studio";
public static final String CHANNEL_SUBTITLE = "subtitle";
public static final String CHANNEL_TITLE = "title";
public static final String CHANNEL_TRACK_NUMBER = "trackNumber";
/**
* These are channels that map directly. Images and location are unique channels that
* don't fit this description.
*/
public static final Set<String> METADATA_SIMPLE_CHANNELS = Collections
.unmodifiableSet(Stream
.of(CHANNEL_ALBUM_ARTIST, CHANNEL_ALBUM_NAME, CHANNEL_ARTIST, CHANNEL_BROADCAST_DATE,
CHANNEL_COMPOSER, CHANNEL_CREATION_DATE, CHANNEL_DISC_NUMBER, CHANNEL_EPISODE_NUMBER,
CHANNEL_LOCATION_NAME, CHANNEL_RELEASE_DATE, CHANNEL_SEASON_NUMBER, CHANNEL_SERIES_TITLE,
CHANNEL_STUDIO, CHANNEL_SUBTITLE, CHANNEL_TITLE, CHANNEL_TRACK_NUMBER)
.collect(Collectors.toSet()));
// We don't key these metadata keys directly to a channel, they get linked together
// into a Location channel.
public static final String LOCATION_METADATA_LATITUDE = "locationLatitude";
public static final String LOCATION_METADATA_LONGITUDE = "locationLongitude";
}

View File

@@ -0,0 +1,262 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.chromecast.internal;
import static org.openhab.binding.chromecast.internal.ChromecastBindingConstants.*;
import static org.openhab.core.thing.ThingStatusDetail.COMMUNICATION_ERROR;
import java.io.IOException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.library.types.IncreaseDecreaseType;
import org.openhab.core.library.types.NextPreviousType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.PlayPauseType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import su.litvak.chromecast.api.v2.Application;
import su.litvak.chromecast.api.v2.ChromeCast;
import su.litvak.chromecast.api.v2.MediaStatus;
import su.litvak.chromecast.api.v2.Status;
/**
* This sends the various commands to the Chromecast.
*
* @author Jason Holmes - Initial contribution
*/
@NonNullByDefault
public class ChromecastCommander {
private final Logger logger = LoggerFactory.getLogger(ChromecastCommander.class);
private final ChromeCast chromeCast;
private final ChromecastScheduler scheduler;
private final ChromecastStatusUpdater statusUpdater;
private static final int VOLUMESTEP = 10;
public ChromecastCommander(ChromeCast chromeCast, ChromecastScheduler scheduler,
ChromecastStatusUpdater statusUpdater) {
this.chromeCast = chromeCast;
this.scheduler = scheduler;
this.statusUpdater = statusUpdater;
}
public void handleCommand(final ChannelUID channelUID, final Command command) {
if (command instanceof RefreshType) {
scheduler.scheduleRefresh();
return;
}
switch (channelUID.getId()) {
case CHANNEL_CONTROL:
handleControl(command);
break;
case CHANNEL_STOP:
handleStop(command);
break;
case CHANNEL_VOLUME:
handleVolume(command);
break;
case CHANNEL_MUTE:
handleMute(command);
break;
case CHANNEL_PLAY_URI:
handlePlayUri(command);
break;
default:
logger.debug("Received command {} for unknown channel: {}", command, channelUID);
break;
}
}
public void handleRefresh() {
if (!chromeCast.isConnected()) {
scheduler.cancelRefresh();
scheduler.scheduleConnect();
return;
}
Status status;
try {
status = chromeCast.getStatus();
statusUpdater.processStatusUpdate(status);
if (status == null) {
scheduler.cancelRefresh();
}
} catch (IOException ex) {
logger.debug("Failed to request status: {}", ex.getMessage());
statusUpdater.updateStatus(ThingStatus.OFFLINE, COMMUNICATION_ERROR, ex.getMessage());
scheduler.cancelRefresh();
return;
}
try {
if (status != null && status.getRunningApp() != null) {
MediaStatus mediaStatus = chromeCast.getMediaStatus();
statusUpdater.updateMediaStatus(mediaStatus);
if (mediaStatus != null && mediaStatus.playerState == MediaStatus.PlayerState.IDLE
&& mediaStatus.idleReason != null
&& mediaStatus.idleReason != MediaStatus.IdleReason.INTERRUPTED) {
stopMediaPlayerApp();
}
}
} catch (IOException ex) {
logger.debug("Failed to request media status with a running app: {}", ex.getMessage());
// We were just able to request status, so let's not put the device OFFLINE.
}
}
private void handlePlayUri(Command command) {
if (command instanceof StringType) {
playMedia(null, command.toString(), null);
}
}
private void handleControl(final Command command) {
try {
if (command instanceof NextPreviousType) {
// I can't find a way to control next/previous from the API. The Google app doesn't seem to
// allow it either, so I suspect there isn't a way.
logger.info("{} command not yet implemented", command);
return;
}
Application app = chromeCast.getRunningApp();
statusUpdater.updateStatus(ThingStatus.ONLINE);
if (app == null) {
logger.debug("{} command ignored because media player app is not running", command);
return;
}
if (command instanceof PlayPauseType) {
MediaStatus mediaStatus = chromeCast.getMediaStatus();
logger.debug("mediaStatus {}", mediaStatus);
if (mediaStatus == null || mediaStatus.playerState == MediaStatus.PlayerState.IDLE) {
logger.debug("{} command ignored because media is not loaded", command);
return;
}
final PlayPauseType playPause = (PlayPauseType) command;
if (playPause == PlayPauseType.PLAY) {
chromeCast.play();
} else if (playPause == PlayPauseType.PAUSE
&& ((mediaStatus.supportedMediaCommands & 0x00000001) == 0x1)) {
chromeCast.pause();
} else {
logger.info("{} command not supported by current media", command);
}
}
} catch (final IOException e) {
logger.debug("{} command failed: {}", command, e.getMessage());
statusUpdater.updateStatus(ThingStatus.OFFLINE, COMMUNICATION_ERROR, e.getMessage());
}
}
public void handleStop(final Command command) {
if (command == OnOffType.ON) {
try {
chromeCast.stopApp();
statusUpdater.updateStatus(ThingStatus.ONLINE);
} catch (final IOException ex) {
logger.debug("{} command failed: {}", command, ex.getMessage());
statusUpdater.updateStatus(ThingStatus.OFFLINE, COMMUNICATION_ERROR, ex.getMessage());
}
}
}
public void handleVolume(final Command command) {
if (command instanceof PercentType) {
setVolumeInternal((PercentType) command);
} else if (command == IncreaseDecreaseType.INCREASE) {
setVolumeInternal(new PercentType(
Math.min(statusUpdater.getVolume().intValue() + VOLUMESTEP, PercentType.HUNDRED.intValue())));
} else if (command == IncreaseDecreaseType.DECREASE) {
setVolumeInternal(new PercentType(
Math.max(statusUpdater.getVolume().intValue() - VOLUMESTEP, PercentType.ZERO.intValue())));
}
}
private void setVolumeInternal(PercentType volume) {
try {
chromeCast.setVolumeByIncrement(volume.floatValue() / 100);
statusUpdater.updateStatus(ThingStatus.ONLINE);
} catch (final IOException ex) {
logger.debug("Set volume failed: {}", ex.getMessage());
statusUpdater.updateStatus(ThingStatus.OFFLINE, COMMUNICATION_ERROR, ex.getMessage());
}
}
private void handleMute(final Command command) {
if (command instanceof OnOffType) {
final boolean mute = command == OnOffType.ON;
try {
chromeCast.setMuted(mute);
statusUpdater.updateStatus(ThingStatus.ONLINE);
} catch (final IOException ex) {
logger.debug("Mute/unmute volume failed: {}", ex.getMessage());
statusUpdater.updateStatus(ThingStatus.OFFLINE, COMMUNICATION_ERROR, ex.getMessage());
}
}
}
void playMedia(@Nullable String title, @Nullable String url, @Nullable String mimeType) {
try {
if (chromeCast.isAppAvailable(MEDIA_PLAYER)) {
if (!chromeCast.isAppRunning(MEDIA_PLAYER)) {
final Application app = chromeCast.launchApp(MEDIA_PLAYER);
statusUpdater.setAppSessionId(app.sessionId);
logger.debug("Application launched: {}", app);
}
if (url != null) {
// If the current track is paused, launching a new request results in nothing happening, therefore
// resume current track.
MediaStatus ms = chromeCast.getMediaStatus();
if (ms != null && MediaStatus.PlayerState.PAUSED == ms.playerState && url.equals(ms.media.url)) {
logger.debug("Current stream paused, resuming");
chromeCast.play();
} else {
chromeCast.load(title, null, url, mimeType);
}
}
} else {
logger.warn("Missing media player app - cannot process media.");
}
statusUpdater.updateStatus(ThingStatus.ONLINE);
} catch (final IOException e) {
logger.debug("Failed playing media: {}", e.getMessage());
statusUpdater.updateStatus(ThingStatus.OFFLINE, COMMUNICATION_ERROR, e.getMessage());
}
}
private void stopMediaPlayerApp() {
try {
Application app = chromeCast.getRunningApp();
if (app.id.equals(MEDIA_PLAYER) && app.sessionId.equals(statusUpdater.getAppSessionId())) {
chromeCast.stopApp();
logger.debug("Media player app stopped");
}
} catch (final IOException e) {
logger.debug("Failed stopping media player app", e);
}
}
}

View File

@@ -0,0 +1,77 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.chromecast.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import su.litvak.chromecast.api.v2.ChromeCastConnectionEvent;
import su.litvak.chromecast.api.v2.ChromeCastConnectionEventListener;
import su.litvak.chromecast.api.v2.ChromeCastSpontaneousEvent;
import su.litvak.chromecast.api.v2.ChromeCastSpontaneousEventListener;
import su.litvak.chromecast.api.v2.MediaStatus;
import su.litvak.chromecast.api.v2.Status;
/**
* Responsible for listening to events from the Chromecast.
*
* @author Jason Holmes - Initial contribution
*/
@NonNullByDefault
public class ChromecastEventReceiver implements ChromeCastSpontaneousEventListener, ChromeCastConnectionEventListener {
private final Logger logger = LoggerFactory.getLogger(ChromecastEventReceiver.class);
private final ChromecastScheduler scheduler;
private final ChromecastStatusUpdater statusUpdater;
public ChromecastEventReceiver(ChromecastScheduler scheduler, ChromecastStatusUpdater statusUpdater) {
this.scheduler = scheduler;
this.statusUpdater = statusUpdater;
}
@Override
public void connectionEventReceived(final @NonNullByDefault({}) ChromeCastConnectionEvent event) {
if (event.isConnected()) {
statusUpdater.updateStatus(ThingStatus.ONLINE);
scheduler.scheduleRefresh();
} else {
scheduler.cancelRefresh();
statusUpdater.updateStatus(ThingStatus.OFFLINE);
// We might have just had a connection problem, let's try to reconnect.
scheduler.scheduleConnect();
}
}
@Override
public void spontaneousEventReceived(final @NonNullByDefault({}) ChromeCastSpontaneousEvent event) {
switch (event.getType()) {
case CLOSE:
statusUpdater.updateMediaStatus(null);
break;
case MEDIA_STATUS:
statusUpdater.updateMediaStatus(event.getData(MediaStatus.class));
break;
case STATUS:
statusUpdater.processStatusUpdate(event.getData(Status.class));
break;
case UNKNOWN:
logger.debug("Received an 'UNKNOWN' event (class={})", event.getType().getDataClass());
break;
default:
logger.debug("Unhandled event type: {}", event.getType());
break;
}
}
}

View File

@@ -0,0 +1,88 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.chromecast.internal;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Handles scheduling of connect and refresh events.
*
* @author Jason Holmes - Initial contribution
* @author Wouter Born - Make sure only at most one refresh job is scheduled and running
*/
@NonNullByDefault
public class ChromecastScheduler {
private final Logger logger = LoggerFactory.getLogger(ChromecastScheduler.class);
private final ScheduledExecutorService scheduler;
private final long connectDelay;
private final long refreshRate;
private final Runnable connectRunnable;
private final Runnable refreshRunnable;
private @Nullable ScheduledFuture<?> connectFuture;
private @Nullable ScheduledFuture<?> refreshFuture;
public ChromecastScheduler(ScheduledExecutorService scheduler, long connectDelay, Runnable connectRunnable,
long refreshRate, Runnable refreshRunnable) {
this.scheduler = scheduler;
this.connectDelay = connectDelay;
this.connectRunnable = connectRunnable;
this.refreshRate = refreshRate;
this.refreshRunnable = refreshRunnable;
}
public synchronized void destroy() {
cancelConnect();
cancelRefresh();
}
public synchronized void scheduleConnect() {
cancelConnect();
logger.debug("Scheduling connection");
connectFuture = scheduler.schedule(connectRunnable, connectDelay, TimeUnit.SECONDS);
}
private synchronized void cancelConnect() {
logger.debug("Canceling connection");
ScheduledFuture<?> localConnectFuture = connectFuture;
if (localConnectFuture != null) {
localConnectFuture.cancel(true);
connectFuture = null;
}
}
public synchronized void scheduleRefresh() {
cancelRefresh();
logger.debug("Scheduling refresh in {} seconds", refreshRate);
// With an initial delay of 1 second the refresh job can be restarted when several channels are refreshed at
// once e.g. due to channel linking
refreshFuture = scheduler.scheduleWithFixedDelay(refreshRunnable, 1, refreshRate, TimeUnit.SECONDS);
}
public synchronized void cancelRefresh() {
logger.debug("Canceling refresh");
ScheduledFuture<?> localRefreshFuture = refreshFuture;
if (localRefreshFuture != null) {
localRefreshFuture.cancel(true);
refreshFuture = null;
}
}
}

View File

@@ -0,0 +1,336 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.chromecast.internal;
import static org.openhab.binding.chromecast.internal.ChromecastBindingConstants.*;
import static su.litvak.chromecast.api.v2.MediaStatus.PlayerState.*;
import java.io.IOException;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.chromecast.internal.handler.ChromecastHandler;
import org.openhab.binding.chromecast.internal.utils.ByteArrayFileCache;
import org.openhab.core.io.net.http.HttpUtil;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.PlayPauseType;
import org.openhab.core.library.types.PointType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.RawType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.unit.SmartHomeUnits;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import su.litvak.chromecast.api.v2.Application;
import su.litvak.chromecast.api.v2.Media;
import su.litvak.chromecast.api.v2.MediaStatus;
import su.litvak.chromecast.api.v2.Status;
import su.litvak.chromecast.api.v2.Volume;
/**
* Responsible for updating the Thing status based on messages received from a ChromeCast. This doesn't query anything -
* it just parses the messages and updates the Thing. Message handling/scheduling/receiving is done elsewhere.
* <p>
* This also maintains state of both volume and the appSessionId (only if we started playing media).
*
* @author Jason Holmes - Initial contribution
*/
@NonNullByDefault
public class ChromecastStatusUpdater {
private final Logger logger = LoggerFactory.getLogger(ChromecastStatusUpdater.class);
private final Thing thing;
private final ChromecastHandler callback;
private static final ByteArrayFileCache IMAGE_CACHE = new ByteArrayFileCache("org.openhab.binding.chromecast");
private @Nullable String appSessionId;
private PercentType volume = PercentType.ZERO;
public ChromecastStatusUpdater(Thing thing, ChromecastHandler callback) {
this.thing = thing;
this.callback = callback;
}
public PercentType getVolume() {
return volume;
}
public @Nullable String getAppSessionId() {
return appSessionId;
}
public void setAppSessionId(String appSessionId) {
this.appSessionId = appSessionId;
}
public void processStatusUpdate(final @Nullable Status status) {
if (status == null) {
updateStatus(ThingStatus.OFFLINE);
updateAppStatus(null);
updateVolumeStatus(null);
return;
}
if (status.applications == null) {
this.appSessionId = null;
}
updateStatus(ThingStatus.ONLINE);
updateAppStatus(status.getRunningApp());
updateVolumeStatus(status.volume);
}
public void updateAppStatus(final @Nullable Application application) {
State name = UnDefType.UNDEF;
State id = UnDefType.UNDEF;
State statusText = UnDefType.UNDEF;
OnOffType idling = OnOffType.ON;
if (application != null) {
name = new StringType(application.name);
id = new StringType(application.id);
statusText = new StringType(application.statusText);
idling = application.isIdleScreen ? OnOffType.ON : OnOffType.OFF;
}
callback.updateState(CHANNEL_APP_NAME, name);
callback.updateState(CHANNEL_APP_ID, id);
callback.updateState(CHANNEL_STATUS_TEXT, statusText);
callback.updateState(CHANNEL_IDLING, idling);
}
public void updateVolumeStatus(final @Nullable Volume volume) {
if (volume == null) {
return;
}
PercentType value = new PercentType((int) (volume.level * 100));
this.volume = value;
callback.updateState(CHANNEL_VOLUME, value);
callback.updateState(CHANNEL_MUTE, volume.muted ? OnOffType.ON : OnOffType.OFF);
}
public void updateMediaStatus(final @Nullable MediaStatus mediaStatus) {
logger.debug("MEDIA_STATUS {}", mediaStatus);
// In-between songs? It's thinking? It's not doing anything
if (mediaStatus == null) {
callback.updateState(CHANNEL_CONTROL, PlayPauseType.PAUSE);
callback.updateState(CHANNEL_STOP, OnOffType.ON);
callback.updateState(CHANNEL_CURRENT_TIME, UnDefType.UNDEF);
updateMediaInfoStatus(null);
return;
}
switch (mediaStatus.playerState) {
case IDLE:
break;
case PAUSED:
callback.updateState(CHANNEL_CONTROL, PlayPauseType.PAUSE);
callback.updateState(CHANNEL_STOP, OnOffType.OFF);
break;
case BUFFERING:
case LOADING:
case PLAYING:
callback.updateState(CHANNEL_CONTROL, PlayPauseType.PLAY);
callback.updateState(CHANNEL_STOP, OnOffType.OFF);
break;
default:
logger.debug("Unknown media status: {}", mediaStatus.playerState);
break;
}
callback.updateState(CHANNEL_CURRENT_TIME, new QuantityType<>(mediaStatus.currentTime, SmartHomeUnits.SECOND));
// If we're playing, paused or buffering but don't have any MEDIA information don't null everything out.
Media media = mediaStatus.media;
if (media == null && (mediaStatus.playerState == PLAYING || mediaStatus.playerState == PAUSED
|| mediaStatus.playerState == BUFFERING)) {
return;
}
updateMediaInfoStatus(media);
}
private void updateMediaInfoStatus(final @Nullable Media media) {
State duration = UnDefType.UNDEF;
String metadataType = Media.MetadataType.GENERIC.name();
if (media != null) {
metadataType = media.getMetadataType().name();
// duration can be null when a new song is about to play.
if (media.duration != null) {
duration = new QuantityType<>(media.duration, SmartHomeUnits.SECOND);
}
}
callback.updateState(CHANNEL_DURATION, duration);
callback.updateState(CHANNEL_METADATA_TYPE, new StringType(metadataType));
updateMetadataStatus(media == null || media.metadata == null ? Collections.emptyMap() : media.metadata);
}
private void updateMetadataStatus(Map<String, Object> metadata) {
updateLocation(metadata);
updateImage(metadata);
thing.getChannels().stream() //
.map(channel -> channel.getUID())
.filter(channelUID -> METADATA_SIMPLE_CHANNELS.contains(channelUID.getId()))
.forEach(channelUID -> updateChannel(channelUID, metadata));
}
/** Lat/lon are combined into 1 channel so we have to handle them as a special case. */
private void updateLocation(Map<String, Object> metadata) {
if (!callback.isLinked(CHANNEL_LOCATION)) {
return;
}
Double lat = (Double) metadata.get(LOCATION_METADATA_LATITUDE);
Double lon = (Double) metadata.get(LOCATION_METADATA_LONGITUDE);
if (lat == null || lon == null) {
callback.updateState(CHANNEL_LOCATION, UnDefType.UNDEF);
} else {
PointType pointType = new PointType(new DecimalType(lat), new DecimalType(lon));
callback.updateState(CHANNEL_LOCATION, pointType);
}
}
private void updateImage(Map<String, Object> metadata) {
if (!(callback.isLinked(CHANNEL_IMAGE) || (callback.isLinked(CHANNEL_IMAGE_SRC)))) {
return;
}
// Channel name and metadata key don't match.
Object imagesValue = metadata.get("images");
if (imagesValue == null) {
callback.updateState(CHANNEL_IMAGE_SRC, UnDefType.UNDEF);
return;
}
String imageSrc = null;
@SuppressWarnings("unchecked")
List<Map<String, String>> strings = (List<Map<String, String>>) imagesValue;
for (Map<String, String> stringMap : strings) {
String url = stringMap.get("url");
if (url != null) {
imageSrc = url;
break;
}
}
if (callback.isLinked(CHANNEL_IMAGE_SRC)) {
callback.updateState(CHANNEL_IMAGE_SRC, imageSrc == null ? UnDefType.UNDEF : new StringType(imageSrc));
}
if (callback.isLinked(CHANNEL_IMAGE)) {
State image = imageSrc == null ? UnDefType.UNDEF : downloadImageFromCache(imageSrc);
callback.updateState(CHANNEL_IMAGE, image == null ? UnDefType.UNDEF : image);
}
}
private @Nullable RawType downloadImage(String url) {
logger.debug("Trying to download the content of URL '{}'", url);
RawType downloadedImage = HttpUtil.downloadImage(url);
if (downloadedImage == null) {
logger.debug("Failed to download the content of URL '{}'", url);
}
return downloadedImage;
}
private @Nullable RawType downloadImageFromCache(String url) {
if (IMAGE_CACHE.containsKey(url)) {
try {
byte[] bytes = IMAGE_CACHE.get(url);
String contentType = HttpUtil.guessContentTypeFromData(bytes);
return new RawType(bytes,
contentType == null || contentType.isEmpty() ? RawType.DEFAULT_MIME_TYPE : contentType);
} catch (IOException e) {
logger.trace("Failed to download the content of URL '{}'", url, e);
}
} else {
RawType image = downloadImage(url);
if (image != null) {
IMAGE_CACHE.put(url, image.getBytes());
return image;
}
}
return null;
}
private void updateChannel(ChannelUID channelUID, Map<String, Object> metadata) {
if (!callback.isLinked(channelUID)) {
return;
}
Object value = getValue(channelUID.getId(), metadata);
State state;
if (value == null) {
state = UnDefType.UNDEF;
} else if (value instanceof Double) {
state = new DecimalType((Double) value);
} else if (value instanceof Integer) {
state = new DecimalType(((Integer) value).longValue());
} else if (value instanceof String) {
state = new StringType(value.toString());
} else if (value instanceof ZonedDateTime) {
state = new DateTimeType((ZonedDateTime) value);
} else {
state = UnDefType.UNDEF;
logger.warn("Update channel {}: Unsupported value type {}", channelUID, value.getClass().getSimpleName());
}
callback.updateState(channelUID, state);
}
private @Nullable Object getValue(String channelId, @Nullable Map<String, Object> metadata) {
if (metadata == null) {
return null;
}
if (CHANNEL_BROADCAST_DATE.equals(channelId) || CHANNEL_RELEASE_DATE.equals(channelId)
|| CHANNEL_CREATION_DATE.equals(channelId)) {
String dateString = (String) metadata.get(channelId);
return (dateString == null) ? null
: ZonedDateTime.ofInstant(Instant.parse(dateString), ZoneId.systemDefault());
}
return metadata.get(channelId);
}
public void updateStatus(ThingStatus status) {
updateStatus(status, ThingStatusDetail.NONE, null);
}
public void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, @Nullable String description) {
callback.updateStatus(status, statusDetail, description);
}
}

View File

@@ -0,0 +1,28 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.chromecast.internal.config;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Thing configuration from openHAB.
*
* @author Christoph Weitkamp - Initial contribution
*/
@NonNullByDefault
public class ChromecastConfig {
public @Nullable String ipAddress = null;
public int port = 8009;
public long refreshRate = 10;
}

View File

@@ -0,0 +1,108 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.chromecast.internal.discovery;
import static org.openhab.binding.chromecast.internal.ChromecastBindingConstants.*;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import javax.jmdns.ServiceInfo;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.config.discovery.mdns.MDNSDiscoveryParticipant;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link ChromecastDiscoveryParticipant} is responsible for discovering Chromecast devices through UPnP.
*
* @author Kai Kreuzer - Initial contribution
* @author Daniel Walters - Change discovery protocol to mDNS
*/
@Component(immediate = true)
@NonNullByDefault
public class ChromecastDiscoveryParticipant implements MDNSDiscoveryParticipant {
private final Logger logger = LoggerFactory.getLogger(ChromecastDiscoveryParticipant.class);
private static final String PROPERTY_MODEL = "md";
private static final String PROPERTY_FRIENDLY_NAME = "fn";
private static final String PROPERTY_DEVICE_ID = "id";
private static final String SERVICE_TYPE = "_googlecast._tcp.local.";
@Override
public Set<ThingTypeUID> getSupportedThingTypeUIDs() {
return SUPPORTED_THING_TYPES_UIDS;
}
@Override
public String getServiceType() {
return SERVICE_TYPE;
}
@Override
public @Nullable DiscoveryResult createResult(ServiceInfo service) {
final ThingUID uid = getThingUID(service);
if (uid == null) {
return null;
}
final Map<String, Object> properties = new HashMap<>(5);
String host = service.getHostAddresses()[0];
properties.put(HOST, host);
int port = service.getPort();
properties.put(PORT, port);
logger.debug("Chromecast Found: {} {}", host, port);
String id = service.getPropertyString(PROPERTY_DEVICE_ID);
properties.put(DEVICE_ID, id);
String friendlyName = service.getPropertyString(PROPERTY_FRIENDLY_NAME); // friendly name;
final DiscoveryResult result = DiscoveryResultBuilder.create(uid).withThingType(getThingType(service))
.withProperties(properties).withRepresentationProperty(DEVICE_ID).withLabel(friendlyName).build();
return result;
}
private @Nullable ThingTypeUID getThingType(final ServiceInfo service) {
String model = service.getPropertyString(PROPERTY_MODEL); // model
logger.debug("Chromecast Type: {}", model);
if (model == null) {
return null;
}
if (model.equals("Chromecast Audio")) {
return THING_TYPE_AUDIO;
} else if (model.equals("Google Cast Group")) {
return THING_TYPE_AUDIOGROUP;
} else {
return THING_TYPE_CHROMECAST;
}
}
@Override
public @Nullable ThingUID getThingUID(ServiceInfo service) {
ThingTypeUID thingTypeUID = getThingType(service);
if (thingTypeUID != null) {
String id = service.getPropertyString(PROPERTY_DEVICE_ID); // device id
return new ThingUID(thingTypeUID, id);
} else {
return null;
}
}
}

View File

@@ -0,0 +1,117 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.chromecast.internal.factory;
import static org.openhab.binding.chromecast.internal.ChromecastBindingConstants.SUPPORTED_THING_TYPES_UIDS;
import java.util.Dictionary;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.chromecast.internal.handler.ChromecastHandler;
import org.openhab.core.audio.AudioHTTPServer;
import org.openhab.core.audio.AudioSink;
import org.openhab.core.net.HttpServiceUtil;
import org.openhab.core.net.NetworkAddressService;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.framework.ServiceRegistration;
import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link ChromecastHandlerFactory} is responsible for creating things and thing handlers.
*
* @author Kai Kreuzer - Initial contribution
*/
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.chromecast")
@NonNullByDefault
public class ChromecastHandlerFactory extends BaseThingHandlerFactory {
private final Logger logger = LoggerFactory.getLogger(ChromecastHandlerFactory.class);
private final Map<String, ServiceRegistration<AudioSink>> audioSinkRegistrations = new ConcurrentHashMap<>();
private final AudioHTTPServer audioHTTPServer;
private final NetworkAddressService networkAddressService;
/** url (scheme+server+port) to use for playing notification sounds. */
private @Nullable String callbackUrl;
@Activate
public ChromecastHandlerFactory(final @Reference AudioHTTPServer audioHTTPServer,
final @Reference NetworkAddressService networkAddressService) {
logger.debug("Creating new instance of ChromecastHandlerFactory");
this.audioHTTPServer = audioHTTPServer;
this.networkAddressService = networkAddressService;
}
@Override
protected void activate(ComponentContext componentContext) {
super.activate(componentContext);
Dictionary<String, Object> properties = componentContext.getProperties();
callbackUrl = (String) properties.get("callbackUrl");
}
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
ChromecastHandler handler = new ChromecastHandler(thing, audioHTTPServer, createCallbackUrl());
@SuppressWarnings("unchecked")
ServiceRegistration<AudioSink> reg = (ServiceRegistration<AudioSink>) bundleContext
.registerService(AudioSink.class.getName(), handler, null);
audioSinkRegistrations.put(thing.getUID().toString(), reg);
return handler;
}
private @Nullable String createCallbackUrl() {
if (callbackUrl != null) {
return callbackUrl;
} else {
final String ipAddress = networkAddressService.getPrimaryIpv4HostAddress();
if (ipAddress == null) {
logger.warn("No network interface could be found.");
return null;
}
// we do not use SSL as it can cause certificate validation issues.
final int port = HttpServiceUtil.getHttpServicePort(bundleContext);
if (port == -1) {
logger.warn("Cannot find port of the http service.");
return null;
}
return "http://" + ipAddress + ":" + port;
}
}
@Override
public void unregisterHandler(Thing thing) {
super.unregisterHandler(thing);
ServiceRegistration<AudioSink> reg = audioSinkRegistrations.get(thing.getUID().toString());
reg.unregister();
}
}

View File

@@ -0,0 +1,268 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.chromecast.internal.handler;
import java.io.IOException;
import java.util.Collections;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.chromecast.internal.ChromecastAudioSink;
import org.openhab.binding.chromecast.internal.ChromecastCommander;
import org.openhab.binding.chromecast.internal.ChromecastEventReceiver;
import org.openhab.binding.chromecast.internal.ChromecastScheduler;
import org.openhab.binding.chromecast.internal.ChromecastStatusUpdater;
import org.openhab.binding.chromecast.internal.config.ChromecastConfig;
import org.openhab.core.audio.AudioFormat;
import org.openhab.core.audio.AudioHTTPServer;
import org.openhab.core.audio.AudioSink;
import org.openhab.core.audio.AudioStream;
import org.openhab.core.audio.UnsupportedAudioFormatException;
import org.openhab.core.audio.UnsupportedAudioStreamException;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import su.litvak.chromecast.api.v2.ChromeCast;
/**
* The {@link ChromecastHandler} is responsible for handling commands, which are sent to one of the channels. It
* furthermore implements {@link AudioSink} support.
*
* @author Markus Rathgeb, Kai Kreuzer - Initial contribution
* @author Daniel Walters - Online status fix, handle playuri channel and refactor play media code
* @author Jason Holmes - Media Status. Refactor the monolith into separate classes.
*/
@NonNullByDefault
public class ChromecastHandler extends BaseThingHandler implements AudioSink {
private static final Set<AudioFormat> SUPPORTED_FORMATS = Collections
.unmodifiableSet(Stream.of(AudioFormat.MP3, AudioFormat.WAV).collect(Collectors.toSet()));
private static final Set<Class<? extends AudioStream>> SUPPORTED_STREAMS = Collections.singleton(AudioStream.class);
private final Logger logger = LoggerFactory.getLogger(ChromecastHandler.class);
private final AudioHTTPServer audioHTTPServer;
private final @Nullable String callbackUrl;
/**
* The actual implementation. A new one is created each time #initialize is called.
*/
private @Nullable Coordinator coordinator;
/**
* Constructor.
*
* @param thing the thing the coordinator should be created for
* @param audioHTTPServer server for hosting audio streams
* @param callbackUrl url to be used to tell the Chromecast which host to call for audio urls
*/
public ChromecastHandler(final Thing thing, AudioHTTPServer audioHTTPServer, @Nullable String callbackUrl) {
super(thing);
this.audioHTTPServer = audioHTTPServer;
this.callbackUrl = callbackUrl;
}
@Override
public void initialize() {
ChromecastConfig config = getConfigAs(ChromecastConfig.class);
final String ipAddress = config.ipAddress;
if (ipAddress == null || ipAddress.isEmpty()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
"Cannot connect to Chromecast. IP address is not valid or missing.");
return;
}
Coordinator localCoordinator = coordinator;
if (localCoordinator != null && (!localCoordinator.chromeCast.getAddress().equals(ipAddress)
|| (localCoordinator.chromeCast.getPort() != config.port))) {
localCoordinator.destroy();
localCoordinator = coordinator = null;
}
if (localCoordinator == null) {
ChromeCast chromecast = new ChromeCast(ipAddress, config.port);
localCoordinator = new Coordinator(this, thing, chromecast, config.refreshRate, audioHTTPServer,
callbackUrl);
localCoordinator.initialize();
coordinator = localCoordinator;
}
}
@Override
public void dispose() {
Coordinator localCoordinator = coordinator;
if (localCoordinator != null) {
localCoordinator.destroy();
coordinator = null;
}
}
@Override
public void handleCommand(final ChannelUID channelUID, final Command command) {
Coordinator localCoordinator = coordinator;
if (localCoordinator != null) {
localCoordinator.commander.handleCommand(channelUID, command);
} else {
logger.debug("Cannot handle command. No coordinator has been initialized");
}
}
@Override // Just exposing this for ChromecastStatusUpdater.
public void updateState(String channelId, State state) {
super.updateState(channelId, state);
}
@Override // Just exposing this for ChromecastStatusUpdater.
public void updateState(ChannelUID channelUID, State state) {
super.updateState(channelUID, state);
}
@Override // Just exposing this for ChromecastStatusUpdater.
public void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, @Nullable String description) {
super.updateStatus(status, statusDetail, description);
}
@Override // Just exposing this for ChromecastStatusUpdater.
public boolean isLinked(String channelId) {
return super.isLinked(channelId);
}
@Override // Just exposing this for ChromecastStatusUpdater.
public boolean isLinked(ChannelUID channelUID) {
return super.isLinked(channelUID);
}
@Override
public String getId() {
return thing.getUID().toString();
}
@Override
public @Nullable String getLabel(@Nullable Locale locale) {
return thing.getLabel();
}
@Override
public Set<AudioFormat> getSupportedFormats() {
return SUPPORTED_FORMATS;
}
@Override
public Set<Class<? extends AudioStream>> getSupportedStreams() {
return SUPPORTED_STREAMS;
}
@Override
public void process(@Nullable AudioStream audioStream)
throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
Coordinator localCoordinator = coordinator;
if (localCoordinator != null) {
localCoordinator.audioSink.process(audioStream);
} else {
logger.debug("Cannot process audioStream. No coordinator has been initialized.");
}
}
@Override
public PercentType getVolume() throws IOException {
Coordinator localCoordinator = coordinator;
if (localCoordinator != null) {
return localCoordinator.statusUpdater.getVolume();
} else {
throw new IOException("Cannot get volume. No coordinator has been initialized.");
}
}
@Override
public void setVolume(PercentType percentType) throws IOException {
Coordinator localCoordinator = coordinator;
if (localCoordinator != null) {
localCoordinator.commander.handleVolume(percentType);
} else {
throw new IOException("Cannot set volume. No coordinator has been initialized.");
}
}
private static class Coordinator {
private final Logger logger = LoggerFactory.getLogger(Coordinator.class);
private static final long CONNECT_DELAY = 10;
private final ChromeCast chromeCast;
private final ChromecastAudioSink audioSink;
private final ChromecastCommander commander;
private final ChromecastEventReceiver eventReceiver;
private final ChromecastStatusUpdater statusUpdater;
private final ChromecastScheduler scheduler;
private Coordinator(ChromecastHandler handler, Thing thing, ChromeCast chromeCast, long refreshRate,
AudioHTTPServer audioHttpServer, @Nullable String callbackURL) {
this.chromeCast = chromeCast;
this.scheduler = new ChromecastScheduler(handler.scheduler, CONNECT_DELAY, this::connect, refreshRate,
this::refresh);
this.statusUpdater = new ChromecastStatusUpdater(thing, handler);
this.commander = new ChromecastCommander(chromeCast, scheduler, statusUpdater);
this.eventReceiver = new ChromecastEventReceiver(scheduler, statusUpdater);
this.audioSink = new ChromecastAudioSink(commander, audioHttpServer, callbackURL);
}
void initialize() {
chromeCast.registerListener(eventReceiver);
chromeCast.registerConnectionListener(eventReceiver);
this.connect();
}
void destroy() {
chromeCast.unregisterConnectionListener(eventReceiver);
chromeCast.unregisterListener(eventReceiver);
try {
scheduler.destroy();
chromeCast.disconnect();
} catch (final IOException ex) {
logger.debug("Disconnect failed: {}", ex.getMessage());
}
}
private void connect() {
try {
chromeCast.connect();
statusUpdater.updateMediaStatus(null);
statusUpdater.updateStatus(ThingStatus.ONLINE);
} catch (final Exception e) {
statusUpdater.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
e.getMessage());
scheduler.scheduleConnect();
}
}
private void refresh() {
commander.handleRefresh();
}
}
}

View File

@@ -0,0 +1,305 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.chromecast.internal.utils;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.config.core.ConfigConstants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This is a simple file based cache implementation.
*
* @author Christoph Weitkamp - Initial contribution
*/
@NonNullByDefault
public class ByteArrayFileCache {
private final Logger logger = LoggerFactory.getLogger(ByteArrayFileCache.class);
private static final String MD5_ALGORITHM = "MD5";
static final String CACHE_FOLDER_NAME = "cache";
private static final char EXTENSION_SEPARATOR = '.';
private static final char UNIX_SEPARATOR = '/';
private static final char WINDOWS_SEPARATOR = '\\';
private final File cacheFolder;
static final long ONE_DAY_IN_MILLIS = TimeUnit.DAYS.toMillis(1);
private int expiry = 0;
private static final Map<String, File> FILES_IN_CACHE = new ConcurrentHashMap<>();
/**
* Creates a new {@link ByteArrayFileCache} instance for a service. Creates a <code>cache</code> folder under
* <code>$userdata/cache/$servicePID</code>.
*
* @param servicePID PID of the service
*/
public ByteArrayFileCache(String servicePID) {
// TODO track and limit folder size
// TODO support user specific folder
cacheFolder = new File(new File(ConfigConstants.getUserDataFolder(), CACHE_FOLDER_NAME), servicePID);
if (!cacheFolder.exists()) {
logger.debug("Creating cache folder '{}'", cacheFolder.getAbsolutePath());
cacheFolder.mkdirs();
}
logger.debug("Using cache folder '{}'", cacheFolder.getAbsolutePath());
}
/**
* Creates a new {@link ByteArrayFileCache} instance for a service. Creates a <code>cache</code> folder under
* <code>$userdata/cache/$servicePID/</code>.
*
* @param servicePID PID of the service
* @param int the days for how long the files stay in the cache valid. Must be positive. 0 to
* disables this functionality.
*/
public ByteArrayFileCache(String servicePID, int expiry) {
this(servicePID);
if (expiry < 0) {
throw new IllegalArgumentException("Cache expiration time must be greater than or equal to 0");
}
this.expiry = expiry;
}
/**
* Adds a file to the cache. If the cache previously contained a file for the key, the old file is replaced by the
* new content.
*
* @param key the key with which the file is to be associated
* @param content the content for the file to be associated with the specified key
*/
public void put(String key, byte[] content) {
writeFile(getUniqueFile(key), content);
}
/**
* Adds a file to the cache.
*
* @param key the key with which the file is to be associated
* @param content the content for the file to be associated with the specified key
*/
public void putIfAbsent(String key, byte[] content) {
File fileInCache = getUniqueFile(key);
if (fileInCache.exists()) {
logger.debug("File '{}' present in cache", fileInCache.getName());
// update time of last use
fileInCache.setLastModified(System.currentTimeMillis());
} else {
writeFile(fileInCache, content);
}
}
/**
* Adds a file to the cache and returns the content of the file.
*
* @param key the key with which the file is to be associated
* @param content the content for the file to be associated with the specified key
* @return the content of the file associated with the given key
*/
public byte[] putIfAbsentAndGet(String key, byte[] content) {
putIfAbsent(key, content);
return content;
}
/**
* Writes the given content to the given {@link File}.
*
* @param fileInCache the {@link File}
* @param content the content to be written
*/
private void writeFile(File fileInCache, byte[] content) {
logger.debug("Caching file '{}'", fileInCache.getName());
try {
Files.write(fileInCache.toPath(), content);
} catch (IOException e) {
logger.warn("Could not write file '{}' to cache", fileInCache.getName(), e);
}
}
/**
* Checks if the key is present in the cache.
*
* @param key the key whose presence in the cache is to be tested
* @return true if the cache contains a file for the specified key
*/
public boolean containsKey(String key) {
return getUniqueFile(key).exists();
}
/**
* Removes the file associated with the given key from the cache.
*
* @param key the key whose associated file is to be removed
*/
public void remove(String key) {
deleteFile(getUniqueFile(key));
}
/**
* Deletes the given {@link File}.
*
* @param fileInCache the {@link File}
*/
private void deleteFile(File fileInCache) {
if (fileInCache.exists()) {
logger.debug("Deleting file '{}' from cache", fileInCache.getName());
fileInCache.delete();
} else {
logger.debug("File '{}' not found in cache", fileInCache.getName());
}
}
/**
* Removes all files from the cache.
*/
public void clear() {
File[] filesInCache = cacheFolder.listFiles();
if (filesInCache != null && filesInCache.length > 0) {
logger.debug("Deleting all files from cache");
Arrays.stream(filesInCache).forEach(File::delete);
}
}
/**
* Removes expired files from the cache.
*/
public void clearExpired() {
// exit if expiry is set to 0 (disabled)
if (expiry <= 0) {
return;
}
File[] filesInCache = cacheFolder.listFiles();
if (filesInCache != null && filesInCache.length > 0) {
logger.debug("Deleting expired files from cache");
Arrays.stream(filesInCache).filter(file -> isExpired(file)).forEach(File::delete);
}
}
/**
* Checks if the given {@link File} is expired.
*
* @param fileInCache the {@link File}
* @return <code>true</code> if the file is expired, <code>false</code> otherwise
*/
private boolean isExpired(File fileInCache) {
// exit if expiry is set to 0 (disabled)
if (expiry <= 0) {
return false;
}
return expiry * ONE_DAY_IN_MILLIS < System.currentTimeMillis() - fileInCache.lastModified();
}
/**
* Returns the content of the file associated with the given key, if it is present.
*
* @param key the key whose associated file is to be returned
* @return the content of the file associated with the given key
* @throws FileNotFoundException if the given file could not be found in cache
* @throws IOException if an I/O error occurs reading the given file
*/
public byte[] get(String key) throws FileNotFoundException, IOException {
return readFile(getUniqueFile(key));
}
/**
* Reads the content from the given {@link File}, if it is present.
*
* @param fileInCache the {@link File}
* @return the content of the file
* @throws FileNotFoundException if the given file could not be found in cache
* @throws IOException if an I/O error occurs reading the given file
*/
private byte[] readFile(File fileInCache) throws FileNotFoundException, IOException {
if (fileInCache.exists()) {
logger.debug("Reading file '{}' from cache", fileInCache.getName());
// update time of last use
fileInCache.setLastModified(System.currentTimeMillis());
try {
return Files.readAllBytes(fileInCache.toPath());
} catch (IOException e) {
logger.warn("Could not read file '{}' from cache", fileInCache.getName(), e);
throw new IOException(String.format("Could not read file '%s' from cache", fileInCache.getName()));
}
} else {
logger.debug("File '{}' not found in cache", fileInCache.getName());
throw new FileNotFoundException(String.format("File '%s' not found in cache", fileInCache.getName()));
}
}
/**
* Creates a unique {@link File} from the key with which the file is to be associated.
*
* @param key the key with which the file is to be associated
* @return unique file for the file associated with the given key
*/
File getUniqueFile(String key) {
String uniqueFileName = getUniqueFileName(key);
if (FILES_IN_CACHE.containsKey(uniqueFileName)) {
return FILES_IN_CACHE.get(uniqueFileName);
} else {
String fileExtension = getFileExtension(key);
File fileInCache = new File(cacheFolder,
uniqueFileName + (fileExtension == null ? "" : EXTENSION_SEPARATOR + fileExtension));
FILES_IN_CACHE.put(uniqueFileName, fileInCache);
return fileInCache;
}
}
/**
* Gets the extension of a file name.
*
* @param fileName the file name to retrieve the extension of
* @return the extension of the file or null if none exists
*/
@Nullable
String getFileExtension(String fileName) {
int extensionPos = fileName.lastIndexOf(EXTENSION_SEPARATOR);
int lastSeparatorPos = Math.max(fileName.lastIndexOf(UNIX_SEPARATOR), fileName.lastIndexOf(WINDOWS_SEPARATOR));
return lastSeparatorPos > extensionPos ? null : fileName.substring(extensionPos + 1).replaceFirst("\\?.*$", "");
}
/**
* Creates a unique file name from the key with which the file is to be associated.
*
* @param key the key with which the file is to be associated
* @return unique file name for the file associated with the given key
*/
String getUniqueFileName(String key) {
try {
final MessageDigest md = MessageDigest.getInstance(MD5_ALGORITHM);
return String.format("%032x", new BigInteger(1, md.digest(key.getBytes(StandardCharsets.UTF_8))));
} catch (NoSuchAlgorithmException ex) {
// should not happen
logger.error("Could not create MD5 hash for key '{}'", key, ex);
return key;
}
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<binding:binding id="chromecast" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
<name>Chromecast Binding</name>
<description>This is the binding for Google Chromecast devices.</description>
<author>Kai Kreuzer</author>
<config-description>
<parameter name="callbackUrl" type="text">
<label>Callback URL</label>
<description>url to use for playing notification sounds, e.g. http://192.168.0.2:8080</description>
<required>false</required>
</parameter>
</config-description>
</binding:binding>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<config-description:config-descriptions
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
<config-description uri="thing-type:chromecast:device">
<parameter name="ipAddress" type="text">
<context>network-address</context>
<label>Network Address</label>
<description>Network address of the Chromecast device.</description>
<required>true</required>
</parameter>
<parameter name="port" type="integer">
<label>Network Port</label>
<description>Network port of the Chromecast device.</description>
<advanced>true</advanced>
<default>8009</default>
</parameter>
<parameter name="refreshRate" type="integer">
<label>Refresh Rate</label>
<description>
How often the chromecast should schedule a refresh. The chromecast should notify the binding when
something changes, but if you want to track duration you'll need to schedule a refresh more often.
</description>
<advanced>true</advanced>
<default>10</default>
</parameter>
</config-description>
</config-description:config-descriptions>

View File

@@ -0,0 +1,39 @@
# binding
binding.chromecast.name = Chromecast Binding
binding.chromecast.description = Dieses Binding integriert Chromecast Geräte (z.B. Chromecast, Chromecast Audio oder Chromecast Ultra).
# thing types
thing-type.chromecast.audiogroup.label = Chromecast Audiogruppe
thing-type.chromecast.audiogroup.description = Audiogruppe aus mehreren Chromecast Audio oder Media Playern.
thing-type.chromecast.audio.label = Chromecast Audio
thing-type.chromecast.audio.description = Chromecast Audio Player
thing-type.chromecast.chromecast.label = Chromecast
thing-type.chromecast.chromecast.description = Chromecast Media Player
# thing types config
thing-type.config.chromecast.device.ipAddress.label = IP-Adresse
thing-type.config.chromecast.device.ipAddress.description = Lokale IP-Adresse oder Hostname des Chromecast Gerätes.
thing-type.config.chromecast.device.port.label = Port
thing-type.config.chromecast.device.port.description = Port des Chromecast Gerätes.
thing-type.config.chromecast.device.refreshRate.label = Aktualisierungsintervall
thing-type.config.chromecast.device.refreshRate.description = Intervall zur Aktualisierung des Chromecast Gerätes.
# channel types
channel-type.chromecast.stop.label = Stop
channel-type.chromecast.stop.description = Ermöglicht das Stoppen der Wiedergabe.
channel-type.chromecast.playuri.label = URI abspielen
channel-type.chromecast.playuri.description = Ermöglicht das Abspielen einer URI.
channel-type.chromecast.metadataType.label = Medientyp
channel-type.chromecast.metadataType.description = Zeigt den Medientyp des aktuellen Stücks oder Films (z. B. MOVIE, AUDIO_TRACK) an.
channel-type.chromecast.albumName.label = Album
channel-type.chromecast.albumName.description = Zeigt das Album des aktuellen Stücks an.
channel-type.chromecast.metadataType.label = Medientyp
channel-type.chromecast.metadataType.description = Zeigt den Medientyp des aktuellen Stücks oder Films (z. B. movie, song) an.
channel-type.chromecast.image.label = Thumbnail
channel-type.chromecast.image.description = Zeigt das Thumbnail des aktuellen Stücks oder Films an.
channel-type.chromecast.currentTime.label = Laufzeit
channel-type.chromecast.currentTime.description = Zeigt die Laufzeit des aktuellen Stücks oder Films an.
channel-type.chromecast.duration.label = Dauer
channel-type.chromecast.duration.description = Zeigt die Dauer des aktuellen Stücks oder Films an.

View File

@@ -0,0 +1,340 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="chromecast"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<!-- Chromecast Audio Group Thing Type -->
<thing-type id="audiogroup">
<label>Chromecast Audio Group</label>
<description>A Google Chromecast Audio Group device</description>
<channels>
<channel id="control" typeId="system.media-control"/>
<channel id="stop" typeId="stop"/>
<channel id="volume" typeId="system.volume"/>
<channel id="mute" typeId="system.mute"/>
<channel id="playuri" typeId="playuri"/>
<!-- App Information -->
<channel id="appName" typeId="appName"/>
<channel id="appId" typeId="appId"/>
<channel id="idling" typeId="idling"/>
<channel id="statustext" typeId="statustext"/>
<!-- Media Info -->
<channel id="currentTime" typeId="currentTime"/>
<channel id="duration" typeId="duration"/>
<!-- Metadata Info -->
<channel id="metadataType" typeId="metadataType"/>
<channel id="albumArtist" typeId="albumArtist"/>
<channel id="albumName" typeId="albumName"/>
<channel id="artist" typeId="system.media-artist"/>
<channel id="broadcastDate" typeId="broadcastDate"/>
<channel id="composer" typeId="composer"/>
<channel id="creationDate" typeId="creationDate"/>
<channel id="discNumber" typeId="discNumber"/>
<channel id="episodeNumber" typeId="episodeNumber"/>
<channel id="image" typeId="image"/>
<channel id="imageSrc" typeId="imageSrc"/>
<channel id="locationName" typeId="locationName"/>
<channel id="location" typeId="system.location"/>
<channel id="releaseDate" typeId="releaseDate"/>
<channel id="seasonNumber" typeId="seasonNumber"/>
<channel id="seriesTitle" typeId="seriesTitle"/>
<channel id="studio" typeId="studio"/>
<channel id="subtitle" typeId="subtitle"/>
<channel id="title" typeId="system.media-title"/>
<channel id="trackNumber" typeId="trackNumber"/>
</channels>
<representation-property>deviceId</representation-property>
<config-description-ref uri="thing-type:chromecast:device"/>
</thing-type>
<!-- Chromecast Audio Thing Type -->
<thing-type id="audio">
<label>Chromecast Audio</label>
<description>A Google Chromecast Audio device</description>
<channels>
<channel id="control" typeId="system.media-control"/>
<channel id="stop" typeId="stop"/>
<channel id="volume" typeId="system.volume"/>
<channel id="mute" typeId="system.mute"/>
<channel id="playuri" typeId="playuri"/>
<!-- App Information -->
<channel id="appName" typeId="appName"/>
<channel id="appId" typeId="appId"/>
<channel id="idling" typeId="idling"/>
<channel id="statustext" typeId="statustext"/>
<!-- Media Info -->
<channel id="currentTime" typeId="currentTime"/>
<channel id="duration" typeId="duration"/>
<!-- Metadata Info -->
<channel id="metadataType" typeId="metadataType"/>
<channel id="albumArtist" typeId="albumArtist"/>
<channel id="albumName" typeId="albumName"/>
<channel id="artist" typeId="system.media-artist"/>
<channel id="broadcastDate" typeId="broadcastDate"/>
<channel id="composer" typeId="composer"/>
<channel id="creationDate" typeId="creationDate"/>
<channel id="discNumber" typeId="discNumber"/>
<channel id="episodeNumber" typeId="episodeNumber"/>
<channel id="image" typeId="image"/>
<channel id="imageSrc" typeId="imageSrc"/>
<channel id="locationName" typeId="locationName"/>
<channel id="location" typeId="system.location"/>
<channel id="releaseDate" typeId="releaseDate"/>
<channel id="seasonNumber" typeId="seasonNumber"/>
<channel id="seriesTitle" typeId="seriesTitle"/>
<channel id="studio" typeId="studio"/>
<channel id="subtitle" typeId="subtitle"/>
<channel id="title" typeId="system.media-title"/>
<channel id="trackNumber" typeId="trackNumber"/>
</channels>
<representation-property>deviceId</representation-property>
<config-description-ref uri="thing-type:chromecast:device"/>
</thing-type>
<!-- Chromecast HDMI dongle Thing Type -->
<thing-type id="chromecast">
<label>Chromecast</label>
<description>A Google Chromecast streaming device</description>
<channels>
<channel id="control" typeId="system.media-control"/>
<channel id="stop" typeId="stop"/>
<channel id="volume" typeId="system.volume"/>
<channel id="mute" typeId="system.mute"/>
<channel id="playuri" typeId="playuri"/>
<!-- App Information -->
<channel id="appName" typeId="appName"/>
<channel id="appId" typeId="appId"/>
<channel id="idling" typeId="idling"/>
<channel id="statustext" typeId="statustext"/>
<!-- Media Info -->
<channel id="currentTime" typeId="currentTime"/>
<channel id="duration" typeId="duration"/>
<!-- Metadata Info -->
<channel id="metadataType" typeId="metadataType"/>
<channel id="albumArtist" typeId="albumArtist"/>
<channel id="albumName" typeId="albumName"/>
<channel id="artist" typeId="system.media-artist"/>
<channel id="broadcastDate" typeId="broadcastDate"/>
<channel id="composer" typeId="composer"/>
<channel id="creationDate" typeId="creationDate"/>
<channel id="discNumber" typeId="discNumber"/>
<channel id="episodeNumber" typeId="episodeNumber"/>
<channel id="image" typeId="image"/>
<channel id="imageSrc" typeId="imageSrc"/>
<channel id="locationName" typeId="locationName"/>
<channel id="location" typeId="system.location"/>
<channel id="releaseDate" typeId="releaseDate"/>
<channel id="seasonNumber" typeId="seasonNumber"/>
<channel id="seriesTitle" typeId="seriesTitle"/>
<channel id="studio" typeId="studio"/>
<channel id="subtitle" typeId="subtitle"/>
<channel id="title" typeId="system.media-title"/>
<channel id="trackNumber" typeId="trackNumber"/>
</channels>
<representation-property>deviceId</representation-property>
<config-description-ref uri="thing-type:chromecast:device"/>
</thing-type>
<channel-type id="stop">
<item-type>Switch</item-type>
<label>Stop</label>
<description>Stops the player. ON if the player is stopped.</description>
</channel-type>
<channel-type id="playuri" advanced="true">
<item-type>String</item-type>
<label>Play URI</label>
<description>Plays a given URI</description>
</channel-type>
<!--App Information -->
<channel-type id="idling" advanced="true">
<item-type>Switch</item-type>
<label>Idling</label>
<description>Is Chromecast active or idling</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="appName" advanced="true">
<item-type>String</item-type>
<label>App</label>
<description>Name of the currently running application</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="appId" advanced="true">
<item-type>String</item-type>
<label>App Id</label>
<description>Id of the currently running application</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="statustext" advanced="true">
<item-type>String</item-type>
<label>App Status</label>
<description>Status reported by the current application</description>
<state readOnly="true"/>
</channel-type>
<!-- Media Information -->
<channel-type id="currentTime" advanced="true">
<item-type>Number:Time</item-type>
<label>Current Time</label>
<description>Current time of currently playing media</description>
<state readOnly="true" pattern="%d %unit%"/>
</channel-type>
<channel-type id="duration" advanced="true">
<item-type>Number:Time</item-type>
<label>Duration</label>
<description>Length of currently playing media</description>
<state readOnly="true" pattern="%d %unit%"/>
</channel-type>
<!-- Metadata Information -->
<channel-type id="metadataType" advanced="true">
<item-type>String</item-type>
<label>Media Type</label>
<description>The type of the currently playing media. One of GENERIC, MOVIE, TV_SHOW, AUDIO_TRACK, PHOTO</description>
<state readOnly="true">
<options>
<option value="GENERIC"/>
<option value="MOVIE"/>
<option value="TV_SHOW"/>
<option value="AUDIO_TRACK"/>
<option value="PHOTO"/>
</options>
</state>
</channel-type>
<channel-type id="albumArtist" advanced="true">
<item-type>String</item-type>
<label>Album Artist</label>
<description>The name of the album's artist</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="albumName" advanced="true">
<item-type>String</item-type>
<label>Album Name</label>
<description>The name of the album</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="broadcastDate" advanced="true">
<item-type>DateTime</item-type>
<label>Broadcast Date</label>
<description>The broadcast date of the currently playing media</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="composer" advanced="true">
<item-type>String</item-type>
<label>Composer</label>
<description>The composer of the current track</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="creationDate" advanced="true">
<item-type>DateTime</item-type>
<label>Creation Date</label>
<description>The creation date of the currently playing media</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="discNumber" advanced="true">
<item-type>Number</item-type>
<label>Disc Number</label>
<description>The disc number of the currently playing media</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="episodeNumber" advanced="true">
<item-type>Number</item-type>
<label>Episode Number</label>
<description>The episode number of the currently playing media</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="imageSrc" advanced="true">
<item-type>String</item-type>
<label>Image URL</label>
<description>The image URL that represents this media. Normally cover-art or scene from a movie</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="image" advanced="true">
<item-type>Image</item-type>
<label>Image</label>
<description>The image that represents this media. Normally cover-art or scene from a movie</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="locationName" advanced="true">
<item-type>String</item-type>
<label>Location Name</label>
<description>The location of where the current media was taken</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="releaseDate" advanced="true">
<item-type>DateTime</item-type>
<label>Release Date</label>
<description>The release date of the currently playing media</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="seasonNumber" advanced="true">
<item-type>Number</item-type>
<label>Season Number</label>
<description>The season number of the currently playing media</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="seriesTitle" advanced="true">
<item-type>String</item-type>
<label>Series Title</label>
<description>The series title of the currently playing media</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="studio" advanced="true">
<item-type>String</item-type>
<label>Studio</label>
<description>The studio of the currently playing media</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="subtitle" advanced="true">
<item-type>String</item-type>
<label>Subtitle</label>
<description>The subtitle of the currently playing media</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="trackNumber" advanced="true">
<item-type>Number</item-type>
<label>Track Number</label>
<description>The track number of the currently playing media</description>
<state readOnly="true"/>
</channel-type>
</thing:thing-descriptions>