From 945dd1376049f2e90a3a869d2b5650c4d76a8e83 Mon Sep 17 00:00:00 2001 From: mlobstein Date: Thu, 15 Feb 2024 06:09:47 -0600 Subject: [PATCH] [nuvo] Display album art from MPS4 (#16068) * Display album art from MPS4 * Display album art from MPS4 --------- Signed-off-by: Michael Lobstein --- bundles/org.openhab.binding.nuvo/README.md | 3 +- .../nuvo/internal/NuvoBindingConstants.java | 10 ++ .../nuvo/internal/handler/NuvoHandler.java | 149 ++++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.nuvo/README.md b/bundles/org.openhab.binding.nuvo/README.md index cf486d0d82..35e9615a7d 100644 --- a/bundles/org.openhab.binding.nuvo/README.md +++ b/bundles/org.openhab.binding.nuvo/README.md @@ -13,6 +13,7 @@ For users without a serial connector on the server side, you can use a USB to se If you are using the Nuvo MPS4 music server with your Grand Concerto or Essentia G, the binding can connect to the server's IP address on port 5006. Using the MPS4 connection will also allow for greater interaction with the keypads to include custom menus, custom favorite lists and album art display on the CTP-36 keypad. +If using MCS v5.35 or later on the server, content that is playing on MPS4 sources will display the album art to that source's Image channel. You don't need to have your Grand Concerto or Essentia G whole house amplifier device directly connected to your openHAB server. You can connect it for example to a Raspberry Pi and use [ser2net Linux tool](https://sourceforge.net/projects/ser2net/) to make the serial connection available on the LAN (serial over IP). @@ -109,7 +110,7 @@ The following channels are available: | sourceN#track_position (where N= 1-6)| Number:Time | The running time elapsed of the current playing track (ReadOnly) See rules example for updating | | sourceN#button_press (where N= 1-6) | String | Indicates the last button pressed on the keypad for a non NuvoNet source or openHAB NuvoNet source (ReadOnly) | | sourceN#art_url (where N= 1-6) | String | MPS4 Only! The URL of the Album Art JPG for this source that is displayed on a CTP-36. See _very advanced_ rules (SendOnly) | -| sourceN#album_art (where N= 1-6) | Image | The Album Art loaded from the art_url channel for display in a UI widget (ReadOnly) | +| sourceN#album_art (where N= 1-6) | Image | The Album Art loaded from an MPS4 source or from the art_url channel for display in a UI widget (ReadOnly) | ## Full Example diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoBindingConstants.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoBindingConstants.java index e9969873ae..cb097382cc 100644 --- a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoBindingConstants.java +++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoBindingConstants.java @@ -12,6 +12,8 @@ */ package org.openhab.binding.nuvo.internal; +import java.util.List; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.core.thing.ThingTypeUID; @@ -110,4 +112,12 @@ public class NuvoBindingConstants { public static final String HTTP = "http://"; public static final String HTTPS = "https://"; public static final String PLAY_MUSIC_PRESET = "PLAY_MUSIC_PRESET:"; + + public static final List MPS4_PLAYING_MODES = List.of("2", "6", "7", "8"); + public static final List MPS4_IDLE_MODES = List.of("0", "1"); + + public static final String GET_MCS_INSTANCE = "http://%s/api/Script/MRAD.SetZone%%20Zone_%s/MRAD.GetStatus/?clientId=%s"; + public static final String GET_MCS_STATUS = "http://%s/api/Script/SetInstance%%20%s/GetStatus?clientId=%s"; + public static final String GET_MCS_JSON = "http://%s/api/?clientId=%s"; + public static final String GET_MCS_ART = "http://%s/getArt?guid=%s&instance=%s&h=143&w=143&changed=true&c=1&fmt=jpg"; } diff --git a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/handler/NuvoHandler.java b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/handler/NuvoHandler.java index ee884e2035..10546c506d 100644 --- a/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/handler/NuvoHandler.java +++ b/bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/handler/NuvoHandler.java @@ -28,6 +28,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.TreeMap; +import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -136,6 +137,8 @@ public class NuvoHandler extends BaseThingHandler implements NuvoMessageEventLis private static final Pattern ZONE_CFG_EQ_PATTERN = Pattern.compile("^BASS(.*),TREB(.*),BAL(.*),LOUDCMP([0-1])$"); private static final Pattern ZONE_CFG_PATTERN = Pattern.compile( "^ENABLE1,NAME\"(.*)\",SLAVETO(.*),GROUP([0-4]),SOURCES(.*),XSRC(.*),IR(.*),DND(.*),LOCKED(.*),SLAVEEQ(.*)$"); + private static final Pattern MCS_INSTANCE_PATTERN = Pattern.compile("MCSInstance\",\"value\":\"(.*?)\""); + private static final Pattern ART_GUID_PATTERN = Pattern.compile("NowPlayingGuid\",\"value\":\"\\{(.*?)\\}\"\\}"); private final Logger logger = LoggerFactory.getLogger(NuvoHandler.class); private final NuvoStateDescriptionOptionProvider stateDescriptionProvider; @@ -160,10 +163,13 @@ public class NuvoHandler extends BaseThingHandler implements NuvoMessageEventLis private HashMap nuvoNetSrcMap = new HashMap<>(); private HashMap favPrefixMap = new HashMap<>(); private HashMap favoriteMap = new HashMap<>(); + private HashMap sourceZoneMap = new HashMap<>(); + private HashMap sourceInstanceMap = new HashMap<>(); private HashMap albumArtMap = new HashMap<>(); private HashMap albumArtIds = new HashMap<>(); private HashMap dispInfoCache = new HashMap<>(); + private HashMap mps4ArtGuids = new HashMap<>(); Set activeZones = new HashSet<>(1); @@ -173,6 +179,7 @@ public class NuvoHandler extends BaseThingHandler implements NuvoMessageEventLis // Indicates if there is a need to poll status because of a disconnection used for MPS4 systems boolean pollStatusNeeded = true; boolean isMps4 = false; + String mps4Host = BLANK; /** * Constructor @@ -220,6 +227,7 @@ public class NuvoHandler extends BaseThingHandler implements NuvoMessageEventLis } else if (host != null && port != null) { connector = new NuvoIpConnector(host, port, uid); this.isMps4 = (port.intValue() == MPS4_PORT); + mps4Host = host; } else { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Either Serial port or Host & Port must be specifed"); @@ -245,6 +253,13 @@ public class NuvoHandler extends BaseThingHandler implements NuvoMessageEventLis || config.nuvoNetSrc3.equals(2) || config.nuvoNetSrc4.equals(2) || config.nuvoNetSrc5.equals(2) || config.nuvoNetSrc6.equals(2)); + mps4ArtGuids.put(NuvoEnum.SOURCE1, BLANK); + mps4ArtGuids.put(NuvoEnum.SOURCE2, BLANK); + mps4ArtGuids.put(NuvoEnum.SOURCE3, BLANK); + mps4ArtGuids.put(NuvoEnum.SOURCE4, BLANK); + mps4ArtGuids.put(NuvoEnum.SOURCE5, BLANK); + mps4ArtGuids.put(NuvoEnum.SOURCE6, BLANK); + if (this.isAnyOhNuvoNet) { logger.debug("At least one source is configured as an openHAB NuvoNet source"); connector.setAnyOhNuvoNet(true); @@ -442,6 +457,7 @@ public class NuvoHandler extends BaseThingHandler implements NuvoMessageEventLis // update the other group member's selected source updateSrcForZoneGroup(target, String.valueOf(value)); + sourceZoneMap.put(NuvoEnum.valueOf(SOURCE + value), target); } } break; @@ -747,6 +763,20 @@ public class NuvoHandler extends BaseThingHandler implements NuvoMessageEventLis updateChannelState(source, CHANNEL_TRACK_LENGTH, matcher.group(1)); updateChannelState(source, CHANNEL_TRACK_POSITION, matcher.group(2)); updateChannelState(source, CHANNEL_PLAY_MODE, matcher.group(3)); + + // if this is an MPS4 source, the following retrieves album art when the source is playing + if (nuvoNetSrcMap.get(source) == 1 + && isLinked(source.name().toLowerCase() + CHANNEL_DELIMIT + CHANNEL_ALBUM_ART)) { + if (MPS4_PLAYING_MODES.contains(matcher.group(3))) { + logger.debug("DISPINFO update, trying to get album art"); + getMps4AlbumArt(source); + } else if (MPS4_IDLE_MODES.contains(matcher.group(3)) && ZERO.equals(matcher.group(1))) { + // clear album art channel for this source + logger.debug("DISPINFO update- not playing; clearing art, mode: {}", matcher.group(3)); + updateChannelState(source, CHANNEL_ALBUM_ART, UNDEF); + mps4ArtGuids.put(source, BLANK); + } + } } else { logger.debug("no match on message: {}", updateData); } @@ -770,6 +800,7 @@ public class NuvoHandler extends BaseThingHandler implements NuvoMessageEventLis if (matcher.find()) { updateChannelState(zone, CHANNEL_TYPE_POWER, ON); updateChannelState(zone, CHANNEL_TYPE_SOURCE, matcher.group(1)); + sourceZoneMap.put(NuvoEnum.valueOf(SOURCE + matcher.group(1)), zone); // update the other group member's selected source updateSrcForZoneGroup(zone, matcher.group(1)); @@ -1434,4 +1465,122 @@ public class NuvoHandler extends BaseThingHandler implements NuvoMessageEventLis logger.warn("Unknown control command: {}", command); } } + + /** + * Scrapes the MPS4's json api to retrieve the currently playing media's album art + * + * @param source the source that should be queried to load the current album art + */ + private void getMps4AlbumArt(NuvoEnum source) { + final String clientId = UUID.randomUUID().toString(); + + // try to get cached source instance + String instance = sourceInstanceMap.get(source); + + // if not found, need to retrieve from the api, once found these calls will be skipped + if (instance == null) { + // find which zone is using this source + NuvoEnum zone = sourceZoneMap.get(source); + + if (zone == null) { + logger.debug("Unable to determine zone that is using source {}", source); + return; + } else { + try { + final String json = getMcsJson(String.format(GET_MCS_INSTANCE, mps4Host, zone.getNum(), clientId), + clientId); + + Matcher matcher = MCS_INSTANCE_PATTERN.matcher(json); + if (matcher.find()) { + instance = matcher.group(1); + sourceInstanceMap.put(source, instance); + logger.debug("Found instance '{}' for source {}", instance, source); + } else { + logger.debug("No instance match found for json: {}", json); + return; + } + } catch (TimeoutException | ExecutionException e) { + logger.debug("Failed getting instance name", e); + return; + } catch (InterruptedException e) { + logger.debug("InterruptedException getting instance name", e); + Thread.currentThread().interrupt(); + return; + } + } + } + + try { + logger.debug("Using MCS instance '{}' for source {}", instance, source); + final String json = getMcsJson(String.format(GET_MCS_STATUS, mps4Host, instance, clientId), clientId); + + if (json.contains("\"name\":\"PlayState\",\"value\":3}")) { + Matcher matcher = ART_GUID_PATTERN.matcher(json); + if (matcher.find()) { + final String nowPlayingGuid = matcher.group(1); + + // If streaming (NowPlayingType=Radio), always retrieve because the same NowPlayingGuid can + // get a different image written to it by Gracenote when the track changes + if (!mps4ArtGuids.get(source).equals(nowPlayingGuid) + || json.contains("NowPlayingType\",\"value\":\"Radio\"}")) { + ContentResponse artResponse = httpClient + .newRequest(String.format(GET_MCS_ART, mps4Host, nowPlayingGuid, instance)).method(GET) + .timeout(10, TimeUnit.SECONDS).send(); + + if (artResponse.getStatus() == OK_200) { + logger.debug("Retrieved album art for guid: {}", nowPlayingGuid); + updateChannelState(source, CHANNEL_ALBUM_ART, BLANK, artResponse.getContent()); + mps4ArtGuids.put(source, nowPlayingGuid); + } + } else { + logger.debug("Album art has not changed, guid: {}", nowPlayingGuid); + } + } else { + logger.debug("NowPlayingGuid not found"); + } + } else { + logger.debug("PlayState not valid"); + } + } catch (TimeoutException | ExecutionException e) { + logger.debug("Failed getting album art", e); + updateChannelState(source, CHANNEL_ALBUM_ART, UNDEF); + mps4ArtGuids.put(source, BLANK); + } catch (InterruptedException e) { + logger.debug("InterruptedException getting album art", e); + updateChannelState(source, CHANNEL_ALBUM_ART, UNDEF); + mps4ArtGuids.put(source, BLANK); + Thread.currentThread().interrupt(); + } + } + + /** + * Used by getMps4AlbumArt to abstract retrieval of status json from MCS + * + * @param commandUrl the url with the embedded commands to send to MCS + * @param clientId the current clientId + * @return string json result from the command executed + * + * @throws InterruptedException + * @throws TimeoutException + * @throws ExecutionException + */ + private String getMcsJson(String commandUrl, String clientId) + throws InterruptedException, TimeoutException, ExecutionException { + ContentResponse commandResp = httpClient.newRequest(commandUrl).method(GET).timeout(10, TimeUnit.SECONDS) + .send(); + + if (commandResp.getStatus() == OK_200) { + Thread.sleep(SLEEP_BETWEEN_CMD_MS); + ContentResponse jsonResp = httpClient.newRequest(String.format(GET_MCS_JSON, mps4Host, clientId)) + .method(GET).timeout(10, TimeUnit.SECONDS).send(); + if (jsonResp.getStatus() == OK_200) { + return jsonResp.getContentAsString(); + } else { + logger.debug("Got error response {} when getting json from MCS", commandResp.getStatus()); + return BLANK; + } + } + logger.debug("Got error response {} when sending json command url: {}", commandResp.getStatus(), commandUrl); + return BLANK; + } } -- 2.47.3