]> git.basschouten.com Git - openhab-addons.git/commitdiff
[nuvo] Display album art from MPS4 (#16068)
authormlobstein <michael.lobstein@gmail.com>
Thu, 15 Feb 2024 12:09:47 +0000 (06:09 -0600)
committerGitHub <noreply@github.com>
Thu, 15 Feb 2024 12:09:47 +0000 (13:09 +0100)
* Display album art from MPS4
* Display album art from MPS4

---------

Signed-off-by: Michael Lobstein <michael.lobstein@gmail.com>
bundles/org.openhab.binding.nuvo/README.md
bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/NuvoBindingConstants.java
bundles/org.openhab.binding.nuvo/src/main/java/org/openhab/binding/nuvo/internal/handler/NuvoHandler.java

index cf486d0d8290c99cdfbabe4ae072f72445f3a890..35e9615a7d43ec507030e5a3d8e2ddf2807aae0e 100644 (file)
@@ -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
 
index e9969873aeeeab5cb081530e88050d2f39e840db..cb097382cc2b2733bd4d806336613be40dfb5924 100644 (file)
@@ -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<String> MPS4_PLAYING_MODES = List.of("2", "6", "7", "8");
+    public static final List<String> 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";
 }
index ee884e20359fed1b051f4f17acbde8b833a9e63e..10546c506d34337815d5d9bbb23a9eca2ca9a83c 100644 (file)
@@ -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<NuvoEnum, Integer> nuvoNetSrcMap = new HashMap<>();
     private HashMap<NuvoEnum, String> favPrefixMap = new HashMap<>();
     private HashMap<NuvoEnum, String[]> favoriteMap = new HashMap<>();
+    private HashMap<NuvoEnum, NuvoEnum> sourceZoneMap = new HashMap<>();
+    private HashMap<NuvoEnum, String> sourceInstanceMap = new HashMap<>();
 
     private HashMap<NuvoEnum, byte[]> albumArtMap = new HashMap<>();
     private HashMap<NuvoEnum, Integer> albumArtIds = new HashMap<>();
     private HashMap<NuvoEnum, String> dispInfoCache = new HashMap<>();
+    private HashMap<NuvoEnum, String> mps4ArtGuids = new HashMap<>();
 
     Set<Integer> 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;
+    }
 }