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;
15 import static org.openhab.binding.chromecast.internal.ChromecastBindingConstants.*;
16 import static su.litvak.chromecast.api.v2.MediaStatus.PlayerState.*;
18 import java.io.IOException;
19 import java.time.Instant;
20 import java.time.ZoneId;
21 import java.time.ZonedDateTime;
22 import java.util.Collections;
23 import java.util.List;
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.openhab.binding.chromecast.internal.handler.ChromecastHandler;
29 import org.openhab.core.cache.ByteArrayFileCache;
30 import org.openhab.core.io.net.http.HttpUtil;
31 import org.openhab.core.library.types.DateTimeType;
32 import org.openhab.core.library.types.DecimalType;
33 import org.openhab.core.library.types.OnOffType;
34 import org.openhab.core.library.types.PercentType;
35 import org.openhab.core.library.types.PlayPauseType;
36 import org.openhab.core.library.types.PointType;
37 import org.openhab.core.library.types.QuantityType;
38 import org.openhab.core.library.types.RawType;
39 import org.openhab.core.library.types.StringType;
40 import org.openhab.core.library.unit.Units;
41 import org.openhab.core.thing.ChannelUID;
42 import org.openhab.core.thing.Thing;
43 import org.openhab.core.thing.ThingStatus;
44 import org.openhab.core.thing.ThingStatusDetail;
45 import org.openhab.core.types.State;
46 import org.openhab.core.types.UnDefType;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
50 import su.litvak.chromecast.api.v2.Application;
51 import su.litvak.chromecast.api.v2.Media;
52 import su.litvak.chromecast.api.v2.MediaStatus;
53 import su.litvak.chromecast.api.v2.Status;
54 import su.litvak.chromecast.api.v2.Volume;
57 * Responsible for updating the Thing status based on messages received from a ChromeCast. This doesn't query anything -
58 * it just parses the messages and updates the Thing. Message handling/scheduling/receiving is done elsewhere.
60 * This also maintains state of both volume and the appSessionId (only if we started playing media).
62 * @author Jason Holmes - Initial contribution
65 public class ChromecastStatusUpdater {
67 private final Logger logger = LoggerFactory.getLogger(ChromecastStatusUpdater.class);
69 private final Thing thing;
70 private final ChromecastHandler callback;
71 private static final ByteArrayFileCache IMAGE_CACHE = new ByteArrayFileCache("org.openhab.binding.chromecast");
73 private @Nullable String appSessionId;
74 private PercentType volume = PercentType.ZERO;
76 // Null is valid value for last duration
77 private @Nullable Double lastDuration = null;
79 public ChromecastStatusUpdater(Thing thing, ChromecastHandler callback) {
81 this.callback = callback;
84 public PercentType getVolume() {
88 public @Nullable Double getLastDuration() {
92 public @Nullable String getAppSessionId() {
96 public void setAppSessionId(String appSessionId) {
97 this.appSessionId = appSessionId;
100 public void processStatusUpdate(final @Nullable Status status) {
101 if (status == null) {
102 updateStatus(ThingStatus.OFFLINE);
103 updateAppStatus(null);
104 updateVolumeStatus(null);
108 if (status.applications == null) {
109 this.appSessionId = null;
112 updateStatus(ThingStatus.ONLINE);
113 updateAppStatus(status.getRunningApp());
114 updateVolumeStatus(status.volume);
117 public void updateAppStatus(final @Nullable Application application) {
118 State name = UnDefType.UNDEF;
119 State id = UnDefType.UNDEF;
120 State statusText = UnDefType.UNDEF;
121 OnOffType idling = OnOffType.ON;
123 if (application != null) {
124 name = new StringType(application.name);
125 id = new StringType(application.id);
126 statusText = new StringType(application.statusText);
127 idling = application.isIdleScreen ? OnOffType.ON : OnOffType.OFF;
130 callback.updateState(CHANNEL_APP_NAME, name);
131 callback.updateState(CHANNEL_APP_ID, id);
132 callback.updateState(CHANNEL_STATUS_TEXT, statusText);
133 callback.updateState(CHANNEL_IDLING, idling);
136 public void updateVolumeStatus(final @Nullable Volume volume) {
137 if (volume == null) {
141 PercentType value = new PercentType((int) (volume.level * 100));
144 callback.updateState(CHANNEL_VOLUME, value);
145 callback.updateState(CHANNEL_MUTE, volume.muted ? OnOffType.ON : OnOffType.OFF);
148 public void updateMediaStatus(final @Nullable MediaStatus mediaStatus) {
149 logger.debug("MEDIA_STATUS {}", mediaStatus);
151 // In-between songs? It's thinking? It's not doing anything
152 if (mediaStatus == null) {
153 callback.updateState(CHANNEL_CONTROL, PlayPauseType.PAUSE);
154 callback.updateState(CHANNEL_STOP, OnOffType.ON);
155 callback.updateState(CHANNEL_CURRENT_TIME, UnDefType.UNDEF);
156 updateMediaInfoStatus(null);
160 if (mediaStatus.playerState != null) {
161 switch (mediaStatus.playerState) {
165 callback.updateState(CHANNEL_CONTROL, PlayPauseType.PAUSE);
166 callback.updateState(CHANNEL_STOP, OnOffType.OFF);
171 callback.updateState(CHANNEL_CONTROL, PlayPauseType.PLAY);
172 callback.updateState(CHANNEL_STOP, OnOffType.OFF);
175 logger.debug("Unknown media status: {}", mediaStatus.playerState);
180 callback.updateState(CHANNEL_CURRENT_TIME, new QuantityType<>(mediaStatus.currentTime, Units.SECOND));
182 // If we're playing, paused or buffering but don't have any MEDIA information don't null everything out.
183 Media media = mediaStatus.media;
184 if (media == null && (mediaStatus.playerState == null || mediaStatus.playerState == PLAYING
185 || mediaStatus.playerState == PAUSED || mediaStatus.playerState == BUFFERING)) {
189 updateMediaInfoStatus(media);
192 private void updateMediaInfoStatus(final @Nullable Media media) {
193 State duration = UnDefType.UNDEF;
194 String metadataType = Media.MetadataType.GENERIC.name();
196 metadataType = media.getMetadataType().name();
198 lastDuration = media.duration;
199 // duration can be null when a new song is about to play.
200 if (media.duration != null) {
201 duration = new QuantityType<>(media.duration, Units.SECOND);
205 callback.updateState(CHANNEL_DURATION, duration);
206 callback.updateState(CHANNEL_METADATA_TYPE, new StringType(metadataType));
208 updateMetadataStatus(media == null || media.metadata == null ? Collections.emptyMap() : media.metadata);
211 private void updateMetadataStatus(Map<String, Object> metadata) {
212 updateLocation(metadata);
213 updateImage(metadata);
215 thing.getChannels().stream() //
216 .map(channel -> channel.getUID())
217 .filter(channelUID -> METADATA_SIMPLE_CHANNELS.contains(channelUID.getId()))
218 .forEach(channelUID -> updateChannel(channelUID, metadata));
221 /** Lat/lon are combined into 1 channel so we have to handle them as a special case. */
222 private void updateLocation(Map<String, Object> metadata) {
223 if (!callback.isLinked(CHANNEL_LOCATION)) {
227 Double lat = (Double) metadata.get(LOCATION_METADATA_LATITUDE);
228 Double lon = (Double) metadata.get(LOCATION_METADATA_LONGITUDE);
229 if (lat == null || lon == null) {
230 callback.updateState(CHANNEL_LOCATION, UnDefType.UNDEF);
232 PointType pointType = new PointType(new DecimalType(lat), new DecimalType(lon));
233 callback.updateState(CHANNEL_LOCATION, pointType);
237 private void updateImage(Map<String, Object> metadata) {
238 if (!(callback.isLinked(CHANNEL_IMAGE) || (callback.isLinked(CHANNEL_IMAGE_SRC)))) {
242 // Channel name and metadata key don't match.
243 Object imagesValue = metadata.get("images");
244 if (imagesValue == null) {
245 callback.updateState(CHANNEL_IMAGE_SRC, UnDefType.UNDEF);
249 String imageSrc = null;
250 @SuppressWarnings("unchecked")
251 List<Map<String, String>> strings = (List<Map<String, String>>) imagesValue;
252 for (Map<String, String> stringMap : strings) {
253 String url = stringMap.get("url");
260 if (callback.isLinked(CHANNEL_IMAGE_SRC)) {
261 callback.updateState(CHANNEL_IMAGE_SRC, imageSrc == null ? UnDefType.UNDEF : new StringType(imageSrc));
264 if (callback.isLinked(CHANNEL_IMAGE)) {
265 State image = imageSrc == null ? UnDefType.UNDEF : downloadImageFromCache(imageSrc);
266 callback.updateState(CHANNEL_IMAGE, image == null ? UnDefType.UNDEF : image);
270 private @Nullable RawType downloadImage(String url) {
271 logger.debug("Trying to download the content of URL '{}'", url);
273 RawType downloadedImage = HttpUtil.downloadImage(url);
274 if (downloadedImage == null) {
275 logger.debug("Failed to download the content of URL '{}'", url);
277 return downloadedImage;
278 } catch (IllegalArgumentException e) {
279 // we catch this exception to avoid confusion errors in the log file
280 // see https://github.com/openhab/openhab-core/issues/2494#issuecomment-970162025
285 private @Nullable RawType downloadImageFromCache(String url) {
286 if (IMAGE_CACHE.containsKey(url)) {
288 byte[] bytes = IMAGE_CACHE.get(url);
289 String contentType = HttpUtil.guessContentTypeFromData(bytes);
290 return new RawType(bytes,
291 contentType == null || contentType.isEmpty() ? RawType.DEFAULT_MIME_TYPE : contentType);
292 } catch (IOException e) {
293 logger.trace("Failed to download the content of URL '{}'", url, e);
296 RawType image = downloadImage(url);
298 IMAGE_CACHE.put(url, image.getBytes());
305 private void updateChannel(ChannelUID channelUID, Map<String, Object> metadata) {
306 if (!callback.isLinked(channelUID)) {
310 Object value = getValue(channelUID.getId(), metadata);
314 state = UnDefType.UNDEF;
315 } else if (value instanceof Double) {
316 state = new DecimalType((Double) value);
317 } else if (value instanceof Integer) {
318 state = new DecimalType(((Integer) value).longValue());
319 } else if (value instanceof String) {
320 state = new StringType(value.toString());
321 } else if (value instanceof ZonedDateTime) {
322 state = new DateTimeType((ZonedDateTime) value);
324 state = UnDefType.UNDEF;
325 logger.warn("Update channel {}: Unsupported value type {}", channelUID, value.getClass().getSimpleName());
328 callback.updateState(channelUID, state);
331 private @Nullable Object getValue(String channelId, @Nullable Map<String, Object> metadata) {
332 if (metadata == null) {
336 if (CHANNEL_BROADCAST_DATE.equals(channelId) || CHANNEL_RELEASE_DATE.equals(channelId)
337 || CHANNEL_CREATION_DATE.equals(channelId)) {
338 String dateString = (String) metadata.get(channelId);
339 return (dateString == null) ? null
340 : ZonedDateTime.ofInstant(Instant.parse(dateString), ZoneId.systemDefault());
343 return metadata.get(channelId);
346 public void updateStatus(ThingStatus status) {
347 updateStatus(status, ThingStatusDetail.NONE, null);
350 public void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, @Nullable String description) {
351 callback.updateStatus(status, statusDetail, description);