[nuvo] Display album art from MPS4 (#16068)

* Display album art from MPS4
* Display album art from MPS4

---------

Signed-off-by: Michael Lobstein <michael.lobstein@gmail.com>
Signed-off-by: Ciprian Pascu <contact@ciprianpascu.ro>
This commit is contained in:
mlobstein 2024-02-15 06:09:47 -06:00 committed by Ciprian Pascu
parent 207f5ca038
commit 267063cffe
3 changed files with 161 additions and 1 deletions

View 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

View 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";
}

View 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;
}
}