]> git.basschouten.com Git - openhab-addons.git/blob
10546c506d34337815d5d9bbb23a9eca2ca9a83c
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.nuvo.internal.handler;
14
15 import static org.eclipse.jetty.http.HttpMethod.GET;
16 import static org.eclipse.jetty.http.HttpStatus.OK_200;
17 import static org.openhab.binding.nuvo.internal.NuvoBindingConstants.*;
18
19 import java.io.StringReader;
20 import java.math.BigDecimal;
21 import java.text.SimpleDateFormat;
22 import java.util.ArrayList;
23 import java.util.Base64;
24 import java.util.Collection;
25 import java.util.Date;
26 import java.util.HashMap;
27 import java.util.HashSet;
28 import java.util.List;
29 import java.util.Set;
30 import java.util.TreeMap;
31 import java.util.UUID;
32 import java.util.concurrent.ExecutionException;
33 import java.util.concurrent.ScheduledFuture;
34 import java.util.concurrent.TimeUnit;
35 import java.util.concurrent.TimeoutException;
36 import java.util.regex.Matcher;
37 import java.util.regex.Pattern;
38 import java.util.stream.Collectors;
39 import java.util.stream.IntStream;
40
41 import javax.measure.Unit;
42 import javax.measure.quantity.Time;
43 import javax.xml.bind.JAXBContext;
44 import javax.xml.bind.JAXBException;
45 import javax.xml.bind.Unmarshaller;
46 import javax.xml.stream.XMLStreamException;
47 import javax.xml.stream.XMLStreamReader;
48
49 import org.eclipse.jdt.annotation.NonNullByDefault;
50 import org.eclipse.jdt.annotation.Nullable;
51 import org.eclipse.jetty.client.HttpClient;
52 import org.eclipse.jetty.client.api.ContentResponse;
53 import org.openhab.binding.nuvo.internal.NuvoException;
54 import org.openhab.binding.nuvo.internal.NuvoStateDescriptionOptionProvider;
55 import org.openhab.binding.nuvo.internal.NuvoThingActions;
56 import org.openhab.binding.nuvo.internal.communication.NuvoCommand;
57 import org.openhab.binding.nuvo.internal.communication.NuvoConnector;
58 import org.openhab.binding.nuvo.internal.communication.NuvoDefaultConnector;
59 import org.openhab.binding.nuvo.internal.communication.NuvoEnum;
60 import org.openhab.binding.nuvo.internal.communication.NuvoImageResizer;
61 import org.openhab.binding.nuvo.internal.communication.NuvoIpConnector;
62 import org.openhab.binding.nuvo.internal.communication.NuvoMessageEvent;
63 import org.openhab.binding.nuvo.internal.communication.NuvoMessageEventListener;
64 import org.openhab.binding.nuvo.internal.communication.NuvoSerialConnector;
65 import org.openhab.binding.nuvo.internal.communication.NuvoStatusCodes;
66 import org.openhab.binding.nuvo.internal.configuration.NuvoThingConfiguration;
67 import org.openhab.binding.nuvo.internal.dto.JAXBUtils;
68 import org.openhab.binding.nuvo.internal.dto.NuvoMenu;
69 import org.openhab.binding.nuvo.internal.dto.NuvoMenu.Source.TopMenu;
70 import org.openhab.core.io.transport.serial.SerialPortManager;
71 import org.openhab.core.library.types.DecimalType;
72 import org.openhab.core.library.types.NextPreviousType;
73 import org.openhab.core.library.types.OnOffType;
74 import org.openhab.core.library.types.OpenClosedType;
75 import org.openhab.core.library.types.PercentType;
76 import org.openhab.core.library.types.PlayPauseType;
77 import org.openhab.core.library.types.QuantityType;
78 import org.openhab.core.library.types.RawType;
79 import org.openhab.core.library.types.StringType;
80 import org.openhab.core.library.unit.Units;
81 import org.openhab.core.thing.Channel;
82 import org.openhab.core.thing.ChannelUID;
83 import org.openhab.core.thing.Thing;
84 import org.openhab.core.thing.ThingStatus;
85 import org.openhab.core.thing.ThingStatusDetail;
86 import org.openhab.core.thing.binding.BaseThingHandler;
87 import org.openhab.core.thing.binding.ThingHandlerService;
88 import org.openhab.core.types.Command;
89 import org.openhab.core.types.State;
90 import org.openhab.core.types.StateOption;
91 import org.openhab.core.types.UnDefType;
92 import org.slf4j.Logger;
93 import org.slf4j.LoggerFactory;
94
95 /**
96  * The {@link NuvoHandler} is responsible for handling commands, which are sent to one of the channels.
97  *
98  * Based on the Rotel binding by Laurent Garnier
99  *
100  * @author Michael Lobstein - Initial contribution
101  */
102 @NonNullByDefault
103 public class NuvoHandler extends BaseThingHandler implements NuvoMessageEventListener {
104     private static final long RECON_POLLING_INTERVAL_SEC = 60;
105     private static final long POLLING_INTERVAL_SEC = 30;
106     private static final long CLOCK_SYNC_INTERVAL_SEC = 3600;
107     private static final long INITIAL_POLLING_DELAY_SEC = 30;
108     private static final long INITIAL_CLOCK_SYNC_DELAY_SEC = 10;
109     private static final long PING_TIMEOUT_SEC = 60;
110     // spec says wait 50ms, min is 100
111     private static final long SLEEP_BETWEEN_CMD_MS = 100;
112     private static final Unit<Time> API_SECOND_UNIT = Units.SECOND;
113
114     private static final String ZONE = "ZONE";
115     private static final String SOURCE = "SOURCE";
116     private static final String CHANNEL_DELIMIT = "#";
117     private static final String UNDEF = "UNDEF";
118     private static final String GC_STR = "NV-I8G";
119
120     private static final int MAX_ZONES = 20;
121     private static final int MAX_SRC = 6;
122     private static final int MAX_FAV = 12;
123     private static final int MIN_VOLUME = 0;
124     private static final int MAX_VOLUME = 79;
125     private static final int MIN_EQ = -18;
126     private static final int MAX_EQ = 18;
127
128     private static final int MPS4_PORT = 5006;
129
130     private static final byte[] NO_ART = { 0 };
131
132     private static final Pattern ZONE_PATTERN = Pattern
133             .compile("^ON,SRC(\\d{1}),(MUTE|VOL\\d{1,2}),DND([0-1]),LOCK([0-1])$");
134     private static final Pattern DISP_PATTERN = Pattern.compile("^DISPLINE(\\d{1}),\"(.*)\"$");
135     private static final Pattern DISP_INFO_PATTERN = Pattern
136             .compile("^DISPINFO,DUR(\\d{1,6}),POS(\\d{1,6}),STATUS(\\d{1,2})$");
137     private static final Pattern ZONE_CFG_EQ_PATTERN = Pattern.compile("^BASS(.*),TREB(.*),BAL(.*),LOUDCMP([0-1])$");
138     private static final Pattern ZONE_CFG_PATTERN = Pattern.compile(
139             "^ENABLE1,NAME\"(.*)\",SLAVETO(.*),GROUP([0-4]),SOURCES(.*),XSRC(.*),IR(.*),DND(.*),LOCKED(.*),SLAVEEQ(.*)$");
140     private static final Pattern MCS_INSTANCE_PATTERN = Pattern.compile("MCSInstance\",\"value\":\"(.*?)\"");
141     private static final Pattern ART_GUID_PATTERN = Pattern.compile("NowPlayingGuid\",\"value\":\"\\{(.*?)\\}\"\\}");
142
143     private final Logger logger = LoggerFactory.getLogger(NuvoHandler.class);
144     private final NuvoStateDescriptionOptionProvider stateDescriptionProvider;
145     private final SerialPortManager serialPortManager;
146     private final HttpClient httpClient;
147
148     private @Nullable ScheduledFuture<?> reconnectJob;
149     private @Nullable ScheduledFuture<?> pollingJob;
150     private @Nullable ScheduledFuture<?> clockSyncJob;
151     private @Nullable ScheduledFuture<?> pingJob;
152
153     private NuvoConnector connector = new NuvoDefaultConnector();
154     private long lastEventReceived = System.currentTimeMillis();
155     private int numZones = 1;
156     private String versionString = BLANK;
157     private boolean isGConcerto = false;
158     private Object sequenceLock = new Object();
159
160     private boolean isAnyOhNuvoNet = false;
161     private NuvoMenu nuvoMenus = new NuvoMenu();
162     private HashMap<String, Set<NuvoEnum>> nuvoGroupMap = new HashMap<>();
163     private HashMap<NuvoEnum, Integer> nuvoNetSrcMap = new HashMap<>();
164     private HashMap<NuvoEnum, String> favPrefixMap = new HashMap<>();
165     private HashMap<NuvoEnum, String[]> favoriteMap = new HashMap<>();
166     private HashMap<NuvoEnum, NuvoEnum> sourceZoneMap = new HashMap<>();
167     private HashMap<NuvoEnum, String> sourceInstanceMap = new HashMap<>();
168
169     private HashMap<NuvoEnum, byte[]> albumArtMap = new HashMap<>();
170     private HashMap<NuvoEnum, Integer> albumArtIds = new HashMap<>();
171     private HashMap<NuvoEnum, String> dispInfoCache = new HashMap<>();
172     private HashMap<NuvoEnum, String> mps4ArtGuids = new HashMap<>();
173
174     Set<Integer> activeZones = new HashSet<>(1);
175
176     // A tree map that maps the source ids to source labels
177     TreeMap<String, String> sourceLabels = new TreeMap<>();
178
179     // Indicates if there is a need to poll status because of a disconnection used for MPS4 systems
180     boolean pollStatusNeeded = true;
181     boolean isMps4 = false;
182     String mps4Host = BLANK;
183
184     /**
185      * Constructor
186      */
187     public NuvoHandler(Thing thing, NuvoStateDescriptionOptionProvider stateDescriptionProvider,
188             SerialPortManager serialPortManager, HttpClient httpClient) {
189         super(thing);
190         this.stateDescriptionProvider = stateDescriptionProvider;
191         this.serialPortManager = serialPortManager;
192         this.httpClient = httpClient;
193     }
194
195     @Override
196     public void initialize() {
197         final String uid = this.getThing().getUID().getAsString();
198         NuvoThingConfiguration config = getConfigAs(NuvoThingConfiguration.class);
199         final String serialPort = config.serialPort;
200         final String host = config.host;
201         final Integer port = config.port;
202         final Integer numZones = config.numZones;
203
204         // Check configuration settings
205         String configError = null;
206         if ((serialPort == null || serialPort.isEmpty()) && (host == null || host.isEmpty())) {
207             configError = "undefined serialPort and host configuration settings; please set one of them";
208         } else if (serialPort != null && (host == null || host.isEmpty())) {
209             if (serialPort.toLowerCase().startsWith("rfc2217")) {
210                 configError = "use host and port configuration settings for a serial over IP connection";
211             }
212         } else {
213             if (port == null) {
214                 configError = "undefined port configuration setting";
215             } else if (port <= 0) {
216                 configError = "invalid port configuration setting";
217             }
218         }
219
220         if (configError != null) {
221             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configError);
222             return;
223         }
224
225         if (serialPort != null && !serialPort.isEmpty()) {
226             connector = new NuvoSerialConnector(serialPortManager, serialPort, uid);
227         } else if (host != null && port != null) {
228             connector = new NuvoIpConnector(host, port, uid);
229             this.isMps4 = (port.intValue() == MPS4_PORT);
230             mps4Host = host;
231         } else {
232             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
233                     "Either Serial port or Host & Port must be specifed");
234             return;
235         }
236
237         nuvoNetSrcMap.put(NuvoEnum.SOURCE1, config.nuvoNetSrc1);
238         nuvoNetSrcMap.put(NuvoEnum.SOURCE2, config.nuvoNetSrc2);
239         nuvoNetSrcMap.put(NuvoEnum.SOURCE3, config.nuvoNetSrc3);
240         nuvoNetSrcMap.put(NuvoEnum.SOURCE4, config.nuvoNetSrc4);
241         nuvoNetSrcMap.put(NuvoEnum.SOURCE5, config.nuvoNetSrc5);
242         nuvoNetSrcMap.put(NuvoEnum.SOURCE6, config.nuvoNetSrc6);
243
244         nuvoGroupMap.put("1", new HashSet<>());
245         nuvoGroupMap.put("2", new HashSet<>());
246         nuvoGroupMap.put("3", new HashSet<>());
247         nuvoGroupMap.put("4", new HashSet<>());
248
249         if (this.isMps4) {
250             logger.debug("Port set to {} configuring binding for MPS4 compatability", MPS4_PORT);
251
252             this.isAnyOhNuvoNet = (config.nuvoNetSrc1.equals(2) || config.nuvoNetSrc2.equals(2)
253                     || config.nuvoNetSrc3.equals(2) || config.nuvoNetSrc4.equals(2) || config.nuvoNetSrc5.equals(2)
254                     || config.nuvoNetSrc6.equals(2));
255
256             mps4ArtGuids.put(NuvoEnum.SOURCE1, BLANK);
257             mps4ArtGuids.put(NuvoEnum.SOURCE2, BLANK);
258             mps4ArtGuids.put(NuvoEnum.SOURCE3, BLANK);
259             mps4ArtGuids.put(NuvoEnum.SOURCE4, BLANK);
260             mps4ArtGuids.put(NuvoEnum.SOURCE5, BLANK);
261             mps4ArtGuids.put(NuvoEnum.SOURCE6, BLANK);
262
263             if (this.isAnyOhNuvoNet) {
264                 logger.debug("At least one source is configured as an openHAB NuvoNet source");
265                 connector.setAnyOhNuvoNet(true);
266                 loadMenuConfiguration(config);
267
268                 favoriteMap.put(NuvoEnum.SOURCE1,
269                         !config.favoritesSrc1.isEmpty() ? config.favoritesSrc1.split(COMMA) : new String[0]);
270                 favoriteMap.put(NuvoEnum.SOURCE2,
271                         !config.favoritesSrc2.isEmpty() ? config.favoritesSrc2.split(COMMA) : new String[0]);
272                 favoriteMap.put(NuvoEnum.SOURCE3,
273                         !config.favoritesSrc3.isEmpty() ? config.favoritesSrc3.split(COMMA) : new String[0]);
274                 favoriteMap.put(NuvoEnum.SOURCE4,
275                         !config.favoritesSrc4.isEmpty() ? config.favoritesSrc4.split(COMMA) : new String[0]);
276                 favoriteMap.put(NuvoEnum.SOURCE5,
277                         !config.favoritesSrc5.isEmpty() ? config.favoritesSrc5.split(COMMA) : new String[0]);
278                 favoriteMap.put(NuvoEnum.SOURCE6,
279                         !config.favoritesSrc6.isEmpty() ? config.favoritesSrc6.split(COMMA) : new String[0]);
280
281                 favPrefixMap.put(NuvoEnum.SOURCE1, config.favPrefix1);
282                 favPrefixMap.put(NuvoEnum.SOURCE2, config.favPrefix2);
283                 favPrefixMap.put(NuvoEnum.SOURCE3, config.favPrefix3);
284                 favPrefixMap.put(NuvoEnum.SOURCE4, config.favPrefix4);
285                 favPrefixMap.put(NuvoEnum.SOURCE5, config.favPrefix5);
286                 favPrefixMap.put(NuvoEnum.SOURCE6, config.favPrefix6);
287
288                 albumArtIds.put(NuvoEnum.SOURCE1, 0);
289                 albumArtIds.put(NuvoEnum.SOURCE2, 0);
290                 albumArtIds.put(NuvoEnum.SOURCE3, 0);
291                 albumArtIds.put(NuvoEnum.SOURCE4, 0);
292                 albumArtIds.put(NuvoEnum.SOURCE5, 0);
293                 albumArtIds.put(NuvoEnum.SOURCE6, 0);
294
295                 albumArtMap.put(NuvoEnum.SOURCE1, NO_ART);
296                 albumArtMap.put(NuvoEnum.SOURCE2, NO_ART);
297                 albumArtMap.put(NuvoEnum.SOURCE3, NO_ART);
298                 albumArtMap.put(NuvoEnum.SOURCE4, NO_ART);
299                 albumArtMap.put(NuvoEnum.SOURCE5, NO_ART);
300                 albumArtMap.put(NuvoEnum.SOURCE6, NO_ART);
301             }
302         }
303
304         if (numZones != null) {
305             this.numZones = numZones;
306         }
307
308         activeZones = IntStream.range((1), (this.numZones + 1)).boxed().collect(Collectors.toSet());
309
310         // remove the channels for the zones we are not using
311         if (this.numZones < MAX_ZONES) {
312             List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
313
314             List<Integer> zonesToRemove = IntStream.range((this.numZones + 1), (MAX_ZONES + 1)).boxed()
315                     .collect(Collectors.toList());
316
317             zonesToRemove.forEach(zone -> channels.removeIf(c -> (c.getUID().getId().contains("zone" + zone))));
318             updateThing(editThing().withChannels(channels).build());
319         }
320
321         // Build a list of State options for the global favorites using user config values (if supplied)
322         String[] favoritesArr = !config.favoriteLabels.isEmpty() ? config.favoriteLabels.split(COMMA) : new String[0];
323         List<StateOption> favoriteLabelsStateOptions = new ArrayList<>();
324         for (int i = 0; i < MAX_FAV; i++) {
325             if (favoritesArr.length > i) {
326                 favoriteLabelsStateOptions.add(new StateOption(String.valueOf(i + 1), favoritesArr[i]));
327             } else if (favoritesArr.length == 0) {
328                 favoriteLabelsStateOptions.add(new StateOption(String.valueOf(i + 1), "Favorite " + (i + 1)));
329             }
330         }
331
332         // Also add any openHAB NuvoNet source favorites to the list
333         for (int src = 1; src <= MAX_SRC; src++) {
334             NuvoEnum source = NuvoEnum.valueOf(SOURCE + src);
335             String[] favorites = favoriteMap.get(source);
336             if (favorites != null) {
337                 for (int fav = 0; fav < favorites.length; fav++) {
338                     favoriteLabelsStateOptions.add(new StateOption(String.valueOf(src * 100 + fav),
339                             favPrefixMap.get(source) + favorites[fav]));
340                 }
341             }
342         }
343
344         // Put the global favorites labels on all active zones
345         activeZones.forEach(zoneNum -> {
346             stateDescriptionProvider.setStateOptions(
347                     new ChannelUID(getThing().getUID(),
348                             ZONE.toLowerCase() + zoneNum + CHANNEL_DELIMIT + CHANNEL_TYPE_FAVORITE),
349                     favoriteLabelsStateOptions);
350         });
351
352         if (config.clockSync) {
353             scheduleClockSyncJob();
354         }
355
356         scheduleReconnectJob();
357         schedulePollingJob();
358         schedulePingTimeoutJob();
359         updateStatus(ThingStatus.UNKNOWN);
360     }
361
362     @Override
363     public void dispose() {
364         if (this.isAnyOhNuvoNet) {
365             try {
366                 // disable NuvoNet for each source that was configured as an openHAB NuvoNet source
367                 nuvoNetSrcMap.forEach((source, val) -> {
368                     if (val.equals(2)) {
369                         try {
370                             connector.sendCommand(source.getId() + "DISPINFOTWO0,0,0,0,0,0,0");
371                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
372                             connector.sendCommand(
373                                     source.getId() + "DISPLINES0,0,0,\"Source Unavailable\",\"\",\"\",\"\"");
374                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
375                             connector.sendCommand(source.getConfigId() + "NUVONET0");
376                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
377                         } catch (NuvoException | InterruptedException e) {
378                             logger.debug("Error sending command to disable NuvoNet source: {}", source.getNum());
379                         }
380                     }
381                 });
382
383                 // need '1' flag for sources configured as an MPS4 NuvoNet source, but disable openHAB NuvoNet sources
384                 connector.sendCommand("SNUMBERS" + (nuvoNetSrcMap.get(NuvoEnum.SOURCE1).equals(1) ? ONE : ZERO) + COMMA
385                         + (nuvoNetSrcMap.get(NuvoEnum.SOURCE2).equals(1) ? ONE : ZERO) + COMMA
386                         + (nuvoNetSrcMap.get(NuvoEnum.SOURCE3).equals(1) ? ONE : ZERO) + COMMA
387                         + (nuvoNetSrcMap.get(NuvoEnum.SOURCE4).equals(1) ? ONE : ZERO) + COMMA
388                         + (nuvoNetSrcMap.get(NuvoEnum.SOURCE5).equals(1) ? ONE : ZERO) + COMMA
389                         + (nuvoNetSrcMap.get(NuvoEnum.SOURCE6).equals(1) ? ONE : ZERO));
390             } catch (NuvoException e) {
391                 logger.debug("Error sending SNUMBERS command to disable NuvoNet sources");
392             }
393         }
394
395         cancelReconnectJob();
396         cancelPollingJob();
397         cancelClockSyncJob();
398         cancelPingTimeoutJob();
399         closeConnection();
400         super.dispose();
401     }
402
403     @Override
404     public Collection<Class<? extends ThingHandlerService>> getServices() {
405         return List.of(NuvoThingActions.class);
406     }
407
408     public void handleRawCommand(String command) {
409         synchronized (sequenceLock) {
410             try {
411                 connector.sendCommand(command);
412             } catch (NuvoException e) {
413                 logger.warn("Nuvo Command: {} failed", command);
414             }
415         }
416     }
417
418     /**
419      * Handle a command from the UI
420      *
421      * @param channelUID the channel sending the command
422      * @param command the command received
423      *
424      */
425     @Override
426     public void handleCommand(ChannelUID channelUID, Command command) {
427         String channel = channelUID.getId();
428         String[] channelSplit = channel.split(CHANNEL_DELIMIT);
429         NuvoEnum target = NuvoEnum.valueOf(channelSplit[0].toUpperCase());
430
431         String channelType = channelSplit[1];
432
433         if (getThing().getStatus() != ThingStatus.ONLINE) {
434             logger.debug("Thing is not ONLINE; command {} from channel {} is ignored", command, channel);
435             return;
436         }
437
438         synchronized (sequenceLock) {
439             if (!connector.isConnected()) {
440                 logger.warn("Command {} from channel {} is ignored: connection not established", command, channel);
441                 return;
442             }
443
444             try {
445                 switch (channelType) {
446                     case CHANNEL_TYPE_POWER:
447                         if (command instanceof OnOffType) {
448                             connector.sendCommand(target, command == OnOffType.ON ? NuvoCommand.ON : NuvoCommand.OFF);
449                         }
450                         break;
451                     case CHANNEL_TYPE_SOURCE:
452                         if (command instanceof DecimalType decimalCommand) {
453                             int value = decimalCommand.intValue();
454                             if (value >= 1 && value <= MAX_SRC) {
455                                 logger.debug("Got source command {} zone {}", value, target);
456                                 connector.sendCommand(target, NuvoCommand.SOURCE, String.valueOf(value));
457
458                                 // update the other group member's selected source
459                                 updateSrcForZoneGroup(target, String.valueOf(value));
460                                 sourceZoneMap.put(NuvoEnum.valueOf(SOURCE + value), target);
461                             }
462                         }
463                         break;
464                     case CHANNEL_TYPE_FAVORITE:
465                         if (command instanceof DecimalType decimalCommand) {
466                             int value = decimalCommand.intValue();
467                             if (value >= 1 && value <= MAX_FAV) {
468                                 logger.debug("Got favorite command {} zone {}", value, target);
469                                 connector.sendCommand(target, NuvoCommand.FAVORITE, String.valueOf(value));
470                             } else if (value >= 100 && value <= 650) {
471                                 String sourceNum = String.valueOf(value / 100);
472                                 NuvoEnum source = NuvoEnum.valueOf(SOURCE + sourceNum);
473                                 updateChannelState(source, CHANNEL_BUTTON_PRESS,
474                                         PLAY_MUSIC_PRESET + favoriteMap.get(source)[value % 100]);
475                                 connector.sendCommand(target, NuvoCommand.SOURCE, sourceNum);
476
477                                 // if this zone is in a group, update the other group member's selected source
478                                 updateSrcForZoneGroup(target, sourceNum);
479                             }
480                         }
481                         break;
482                     case CHANNEL_TYPE_VOLUME:
483                         if (command instanceof PercentType percentCommand) {
484                             int value = (MAX_VOLUME
485                                     - (int) Math.round(percentCommand.doubleValue() / 100.0 * (MAX_VOLUME - MIN_VOLUME))
486                                     + MIN_VOLUME);
487                             logger.debug("Got volume command {} zone {}", value, target);
488                             connector.sendCommand(target, NuvoCommand.VOLUME, String.valueOf(value));
489                         }
490                         break;
491                     case CHANNEL_TYPE_MUTE:
492                         if (command instanceof OnOffType) {
493                             connector.sendCommand(target,
494                                     command == OnOffType.ON ? NuvoCommand.MUTE_ON : NuvoCommand.MUTE_OFF);
495                         }
496                         break;
497                     case CHANNEL_TYPE_TREBLE:
498                         if (command instanceof DecimalType decimalCommand) {
499                             int value = decimalCommand.intValue();
500                             if (value >= MIN_EQ && value <= MAX_EQ) {
501                                 // device can only accept even values
502                                 if (value % 2 == 1) {
503                                     value++;
504                                 }
505                                 logger.debug("Got treble command {} zone {}", value, target);
506                                 connector.sendCfgCommand(target, NuvoCommand.TREBLE, String.valueOf(value));
507                             }
508                         }
509                         break;
510                     case CHANNEL_TYPE_BASS:
511                         if (command instanceof DecimalType decimalCommand) {
512                             int value = decimalCommand.intValue();
513                             if (value >= MIN_EQ && value <= MAX_EQ) {
514                                 if (value % 2 == 1) {
515                                     value++;
516                                 }
517                                 logger.debug("Got bass command {} zone {}", value, target);
518                                 connector.sendCfgCommand(target, NuvoCommand.BASS, String.valueOf(value));
519                             }
520                         }
521                         break;
522                     case CHANNEL_TYPE_BALANCE:
523                         if (command instanceof DecimalType decimalCommand) {
524                             int value = decimalCommand.intValue();
525                             if (value >= MIN_EQ && value <= MAX_EQ) {
526                                 if (value % 2 == 1) {
527                                     value++;
528                                 }
529                                 logger.debug("Got balance command {} zone {}", value, target);
530                                 connector.sendCfgCommand(target, NuvoCommand.BALANCE,
531                                         NuvoStatusCodes.getBalanceFromInt(value));
532                             }
533                         }
534                         break;
535                     case CHANNEL_TYPE_LOUDNESS:
536                         if (command instanceof OnOffType) {
537                             connector.sendCfgCommand(target, NuvoCommand.LOUDNESS,
538                                     command == OnOffType.ON ? ONE : ZERO);
539                         }
540                         break;
541                     case CHANNEL_TYPE_CONTROL:
542                         handleControlCommand(target, command);
543                         break;
544                     case CHANNEL_TYPE_DND:
545                         if (command instanceof OnOffType) {
546                             connector.sendCommand(target,
547                                     command == OnOffType.ON ? NuvoCommand.DND_ON : NuvoCommand.DND_OFF);
548                         }
549                         break;
550                     case CHANNEL_TYPE_PARTY:
551                         if (command instanceof OnOffType) {
552                             connector.sendCommand(target,
553                                     command == OnOffType.ON ? NuvoCommand.PARTY_ON : NuvoCommand.PARTY_OFF);
554                         }
555                         break;
556                     case CHANNEL_DISPLAY_LINE1:
557                         if (command instanceof StringType) {
558                             connector.sendCommand(target, NuvoCommand.DISPLINE1, "\"" + command + "\"");
559                         }
560                         break;
561                     case CHANNEL_DISPLAY_LINE2:
562                         if (command instanceof StringType) {
563                             connector.sendCommand(target, NuvoCommand.DISPLINE2, "\"" + command + "\"");
564                         }
565                         break;
566                     case CHANNEL_DISPLAY_LINE3:
567                         if (command instanceof StringType) {
568                             connector.sendCommand(target, NuvoCommand.DISPLINE3, "\"" + command + "\"");
569                         }
570                         break;
571                     case CHANNEL_DISPLAY_LINE4:
572                         if (command instanceof StringType) {
573                             connector.sendCommand(target, NuvoCommand.DISPLINE4, "\"" + command + "\"");
574                         }
575                         break;
576                     case CHANNEL_TYPE_ALLOFF:
577                         if (command instanceof OnOffType) {
578                             connector.sendCommand(NuvoCommand.ALLOFF);
579                         }
580                         break;
581                     case CHANNEL_TYPE_ALLMUTE:
582                         if (command instanceof OnOffType) {
583                             connector.sendCommand(
584                                     command == OnOffType.ON ? NuvoCommand.ALLMUTE_ON : NuvoCommand.ALLMUTE_OFF);
585                         }
586                         break;
587                     case CHANNEL_TYPE_PAGE:
588                         if (command instanceof OnOffType) {
589                             connector.sendCommand(command == OnOffType.ON ? NuvoCommand.PAGE_ON : NuvoCommand.PAGE_OFF);
590                         }
591                         break;
592                     case CHANNEL_TYPE_SENDCMD:
593                         if (command instanceof StringType) {
594                             String commandStr = command.toString();
595                             if (commandStr.contains(DISP_INFO_TWO)) {
596                                 NuvoEnum source = NuvoEnum
597                                         .valueOf(commandStr.split(DISP_INFO_TWO)[0].replace("S", SOURCE));
598                                 dispInfoCache.put(source, commandStr);
599
600                                 // if 'albumartid' is present, substitute it with the albumArtId hex string
601                                 connector.sendCommand(commandStr.replace(ALBUM_ART_ID,
602                                         (OFFSET_ZERO + Integer.toHexString(albumArtIds.get(source)))));
603                             } else {
604                                 connector.sendCommand(commandStr);
605                             }
606                         }
607                         break;
608                     case CHANNEL_ART_URL:
609                         if (command instanceof StringType) {
610                             String url = command.toString();
611                             if (url.startsWith(HTTP) || url.startsWith(HTTPS)) {
612                                 try {
613                                     ContentResponse contentResponse = httpClient.newRequest(url).method(GET)
614                                             .timeout(10, TimeUnit.SECONDS).send();
615                                     int httpStatus = contentResponse.getStatus();
616                                     if (httpStatus == OK_200) {
617                                         albumArtMap.put(target,
618                                                 NuvoImageResizer.resizeImage(contentResponse.getContent(), 80, 80));
619
620                                         updateChannelState(target, CHANNEL_ALBUM_ART, BLANK,
621                                                 contentResponse.getContent());
622                                     } else {
623                                         albumArtMap.put(target, NO_ART);
624                                         albumArtIds.put(target, 0);
625                                         updateChannelState(target, CHANNEL_ALBUM_ART, UNDEF);
626                                         return;
627                                     }
628                                 } catch (InterruptedException | TimeoutException | ExecutionException e) {
629                                     albumArtMap.put(target, NO_ART);
630                                     albumArtIds.put(target, 0);
631                                     updateChannelState(target, CHANNEL_ALBUM_ART, UNDEF);
632                                     return;
633                                 }
634                                 albumArtIds.put(target, Math.abs(url.hashCode()));
635
636                                 // re-send the cached DISPINFOTWO message, substituting in the new albumArtId
637                                 if (dispInfoCache.get(target) != null) {
638                                     connector.sendCommand(dispInfoCache.get(target).replace(ALBUM_ART_ID,
639                                             (OFFSET_ZERO + Integer.toHexString(albumArtIds.get(target)))));
640                                 }
641                             } else {
642                                 albumArtMap.put(target, NO_ART);
643                                 albumArtIds.put(target, 0);
644                                 updateChannelState(target, CHANNEL_ALBUM_ART, UNDEF);
645                             }
646                         }
647                 }
648             } catch (NuvoException e) {
649                 logger.warn("Command {} from channel {} failed: {}", command, channel, e.getMessage());
650                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Sending command failed");
651                 closeConnection();
652                 scheduleReconnectJob();
653             }
654         }
655     }
656
657     /**
658      * Open the connection with the Nuvo device
659      *
660      * @return true if the connection is opened successfully or false if not
661      */
662     private synchronized boolean openConnection() {
663         connector.addEventListener(this);
664         try {
665             connector.open();
666         } catch (NuvoException e) {
667             logger.debug("openConnection() failed: {}", e.getMessage());
668         }
669         logger.debug("openConnection(): {}", connector.isConnected() ? "connected" : "disconnected");
670         return connector.isConnected();
671     }
672
673     /**
674      * Close the connection with the Nuvo device
675      */
676     private synchronized void closeConnection() {
677         if (connector.isConnected()) {
678             connector.close();
679             connector.removeEventListener(this);
680             pollStatusNeeded = true;
681             logger.debug("closeConnection(): disconnected");
682         }
683     }
684
685     /**
686      * Handle an event received from the Nuvo device
687      *
688      * @param evt the event to process
689      */
690     @Override
691     public void onNewMessageEvent(NuvoMessageEvent evt) {
692         logger.debug("onNewMessageEvent: zone {}, source {}, value {}", evt.getZone(), evt.getSrc(), evt.getValue());
693         lastEventReceived = System.currentTimeMillis();
694
695         final NuvoEnum zone = !evt.getZone().isEmpty() ? NuvoEnum.valueOf(ZONE + evt.getZone()) : NuvoEnum.SYSTEM;
696         final NuvoEnum source = !evt.getSrc().isEmpty() ? NuvoEnum.valueOf(SOURCE + evt.getSrc()) : NuvoEnum.SYSTEM;
697         final String sourceZone = source.getId() + zone.getId();
698         final String updateData = evt.getValue().trim();
699
700         if (this.getThing().getStatus() != ThingStatus.ONLINE) {
701             updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, this.versionString);
702         }
703
704         switch (evt.getType()) {
705             case TYPE_VERSION:
706                 this.versionString = updateData;
707                 // Determine if we are a Grand Concerto or not
708                 if (this.versionString.contains(GC_STR)) {
709                     logger.debug("Grand Concerto detected");
710                     this.isGConcerto = true;
711                     connector.setEssentia(false);
712                 } else {
713                     logger.debug("Grand Concerto not detected");
714                 }
715                 break;
716             case TYPE_RESTART:
717                 logger.debug("Restart message received; re-sending initialization messages");
718                 enableNuvonet(false);
719                 return;
720             case TYPE_PING:
721                 logger.debug("Ping message received- rescheduling ping timeout");
722                 schedulePingTimeoutJob();
723                 // Return here because receiving a ping does not indicate that one can poll
724                 return;
725             case TYPE_ALLOFF:
726                 activeZones.forEach(zoneNum -> {
727                     updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_POWER, OFF);
728                 });
729
730                 // Publish the ALLOFF event to all button channels for awareness in source rules
731                 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, ZERO + COMMA + ALLOFF);
732                 NuvoEnum.VALID_SOURCES.forEach(src -> {
733                     updateChannelState(src, CHANNEL_BUTTON_PRESS, ALLOFF);
734                 });
735
736                 break;
737             case TYPE_ALLMUTE:
738                 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_ALLMUTE, ONE.equals(updateData) ? ON : OFF);
739                 activeZones.forEach(zoneNum -> {
740                     updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_MUTE,
741                             ONE.equals(updateData) ? ON : OFF);
742                 });
743                 break;
744             case TYPE_PAGE:
745                 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_PAGE, ONE.equals(updateData) ? ON : OFF);
746                 break;
747             case TYPE_SOURCE_UPDATE:
748                 logger.debug("Source update: Source: {} - Value: {}", source.getNum(), updateData);
749
750                 if (updateData.contains(DISPLINE)) {
751                     // example: DISPLINE2,"Play My Song (Featuring Dee Ajayi)"
752                     Matcher matcher = DISP_PATTERN.matcher(updateData);
753                     if (matcher.find()) {
754                         updateChannelState(source, CHANNEL_DISPLAY_LINE + matcher.group(1), matcher.group(2));
755                     } else {
756                         logger.debug("no match on message: {}", updateData);
757                     }
758                 } else if (updateData.contains(DISPINFO)) {
759                     // example: DISPINFO,DUR0,POS70,STATUS2 (DUR and POS are expressed in tenths of a second)
760                     // 6 places(tenths of a second)-> max 999,999 /10/60/60/24 = 1.15 days
761                     Matcher matcher = DISP_INFO_PATTERN.matcher(updateData);
762                     if (matcher.find()) {
763                         updateChannelState(source, CHANNEL_TRACK_LENGTH, matcher.group(1));
764                         updateChannelState(source, CHANNEL_TRACK_POSITION, matcher.group(2));
765                         updateChannelState(source, CHANNEL_PLAY_MODE, matcher.group(3));
766
767                         // if this is an MPS4 source, the following retrieves album art when the source is playing
768                         if (nuvoNetSrcMap.get(source) == 1
769                                 && isLinked(source.name().toLowerCase() + CHANNEL_DELIMIT + CHANNEL_ALBUM_ART)) {
770                             if (MPS4_PLAYING_MODES.contains(matcher.group(3))) {
771                                 logger.debug("DISPINFO update, trying to get album art");
772                                 getMps4AlbumArt(source);
773                             } else if (MPS4_IDLE_MODES.contains(matcher.group(3)) && ZERO.equals(matcher.group(1))) {
774                                 // clear album art channel for this source
775                                 logger.debug("DISPINFO update- not playing; clearing art, mode: {}", matcher.group(3));
776                                 updateChannelState(source, CHANNEL_ALBUM_ART, UNDEF);
777                                 mps4ArtGuids.put(source, BLANK);
778                             }
779                         }
780                     } else {
781                         logger.debug("no match on message: {}", updateData);
782                     }
783                 } else if (updateData.contains(NAME_QUOTE)) {
784                     // example: NAME"Ipod"
785                     String name = updateData.split("\"")[1];
786                     sourceLabels.put(String.valueOf(source.getNum()), name);
787                 }
788                 break;
789             case TYPE_ZONE_UPDATE:
790                 logger.debug("Zone update: Zone: {} - Value: {}", zone.getNum(), updateData);
791                 // example : OFF
792                 // or: ON,SRC3,VOL63,DND0,LOCK0
793                 // or: ON,SRC3,MUTE,DND0,LOCK0
794
795                 if (OFF.equals(updateData)) {
796                     updateChannelState(zone, CHANNEL_TYPE_POWER, OFF);
797                     updateChannelState(zone, CHANNEL_TYPE_SOURCE, UNDEF);
798                 } else {
799                     Matcher matcher = ZONE_PATTERN.matcher(updateData);
800                     if (matcher.find()) {
801                         updateChannelState(zone, CHANNEL_TYPE_POWER, ON);
802                         updateChannelState(zone, CHANNEL_TYPE_SOURCE, matcher.group(1));
803                         sourceZoneMap.put(NuvoEnum.valueOf(SOURCE + matcher.group(1)), zone);
804
805                         // update the other group member's selected source
806                         updateSrcForZoneGroup(zone, matcher.group(1));
807
808                         if (MUTE.equals(matcher.group(2))) {
809                             updateChannelState(zone, CHANNEL_TYPE_MUTE, ON);
810                         } else {
811                             updateChannelState(zone, CHANNEL_TYPE_MUTE, NuvoCommand.OFF.getValue());
812                             updateChannelState(zone, CHANNEL_TYPE_VOLUME, matcher.group(2).replace(VOL, BLANK));
813                         }
814
815                         updateChannelState(zone, CHANNEL_TYPE_DND, ONE.equals(matcher.group(3)) ? ON : OFF);
816                         updateChannelState(zone, CHANNEL_TYPE_LOCK, ONE.equals(matcher.group(4)) ? ON : OFF);
817                     } else {
818                         logger.debug("no match on message: {}", updateData);
819                     }
820                 }
821                 break;
822             case TYPE_ZONE_SOURCE_BUTTON:
823                 logger.debug("Source Button pressed: Source: {} - Button: {}", source.getNum(), updateData);
824                 updateChannelState(source, CHANNEL_BUTTON_PRESS, updateData);
825                 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, zone.getNum() + COMMA + updateData);
826                 break;
827             case TYPE_NN_BUTTON:
828                 String buttonAction = NuvoStatusCodes.BUTTON_CODE.get(updateData);
829
830                 if (buttonAction != null) {
831                     logger.debug("NuvoNet Source Button pressed: Source: {} - Button: {}", source.getNum(),
832                             buttonAction);
833                     updateChannelState(source, CHANNEL_BUTTON_PRESS, buttonAction);
834                     updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, zone.getNum() + COMMA + buttonAction);
835                 } else {
836                     logger.debug("NuvoNet Source Button pressed: Source: {} - Unknown button code: {}", source.getNum(),
837                             updateData);
838                     updateChannelState(source, CHANNEL_BUTTON_PRESS, updateData);
839                     updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, zone.getNum() + COMMA + updateData);
840                 }
841                 break;
842             case TYPE_NN_MENU_ITEM_SELECTED:
843                 // ignore this update unless openHAB is handling this source
844                 if (nuvoNetSrcMap.get(source).equals(2)) {
845                     String[] updateDataSplit = updateData.split(COMMA);
846                     String menuId = updateDataSplit[0];
847                     int menuItemIdx = Integer.parseInt(updateDataSplit[1]) - 1;
848
849                     boolean exitMenu = false;
850                     if ("0xFFFFFFFF".equals(menuId)) {
851                         TopMenu topMenuItem = nuvoMenus.getSource().get(source.getNum() - 1).getTopMenu()
852                                 .get(menuItemIdx);
853                         logger.debug("Top Menu item selected: Source: {} - Menu Item: {}", source.getNum(),
854                                 topMenuItem.getText());
855                         updateChannelState(source, CHANNEL_BUTTON_PRESS, topMenuItem.getText());
856                         updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS,
857                                 zone.getNum() + COMMA + topMenuItem.getText());
858
859                         List<String> subMenuItems = topMenuItem.getItems();
860
861                         if (subMenuItems.isEmpty()) {
862                             exitMenu = true;
863                         } else {
864                             // send submenu (maximum of 20 items)
865                             int subMenuSize = subMenuItems.size() < 20 ? subMenuItems.size() : 20;
866                             try {
867                                 connector.sendCommand(sourceZone + "MENU" + (menuItemIdx + 11) + ",0,0," + subMenuSize
868                                         + ",0,0," + subMenuSize + ",\"" + topMenuItem.getText() + "\"");
869                                 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
870
871                                 for (int i = 0; i < subMenuSize; i++) {
872                                     connector.sendCommand(
873                                             sourceZone + "MENUITEM" + (i + 1) + ",0,0,\"" + subMenuItems.get(i) + "\"");
874                                 }
875                             } catch (NuvoException | InterruptedException e) {
876                                 logger.debug("Error sending sub menu to {}", sourceZone);
877                             }
878                         }
879                     } else {
880                         // a sub menu item was selected
881                         TopMenu topMenuItem = nuvoMenus.getSource().get(source.getNum() - 1).getTopMenu()
882                                 .get(Integer.decode(menuId) - 11);
883                         String subMenuItem = topMenuItem.getItems().get(menuItemIdx);
884
885                         logger.debug("Sub Menu item selected: Source: {} - Menu Item: {}", source.getNum(),
886                                 topMenuItem.getText() + "|" + subMenuItem);
887                         updateChannelState(source, CHANNEL_BUTTON_PRESS, topMenuItem.getText() + "|" + subMenuItem);
888                         updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS,
889                                 zone.getNum() + COMMA + topMenuItem.getText() + "|" + subMenuItem);
890                         exitMenu = true;
891                     }
892
893                     if (exitMenu) {
894                         try {
895                             // tell the zone to exit the menu
896                             connector.sendCommand(sourceZone + "MENU0,0,0,0,0,0,0,\"\"");
897                         } catch (NuvoException e) {
898                             logger.debug("Error sending exit menu command to {}", sourceZone);
899                         }
900                     }
901                 }
902                 break;
903             case TYPE_NN_MENUREQ:
904                 // ignore this update unless openHAB is handling this source
905                 if (nuvoNetSrcMap.get(source).equals(2)) {
906                     logger.debug("Menu Request: Source: {} - Value: {}", source.getNum(), updateData);
907                     // For now we only support one level deep menus. If second field is '1', indicates go back to main
908                     // menu.
909                     String[] menuDataSplit = updateData.split(COMMA);
910                     if (menuDataSplit.length > 2 && ONE.equals(menuDataSplit[1])) {
911                         try {
912                             connector.sendCommand(sourceZone + "MENU0xFFFFFFFF,0,0,0,0,0,0,\"\"");
913                         } catch (NuvoException e) {
914                             logger.debug("Error sending main menu command to {}", sourceZone);
915                         }
916                     }
917                 }
918                 break;
919             case TYPE_ZONE_CONFIG:
920                 logger.debug("Zone Configuration: Zone: {} - Value: {}", zone.getNum(), updateData);
921                 // example: BASS1,TREB-2,BALR2,LOUDCMP1
922                 Matcher matcher = ZONE_CFG_EQ_PATTERN.matcher(updateData);
923                 if (matcher.find()) {
924                     updateChannelState(zone, CHANNEL_TYPE_BASS, matcher.group(1));
925                     updateChannelState(zone, CHANNEL_TYPE_TREBLE, matcher.group(2));
926                     updateChannelState(zone, CHANNEL_TYPE_BALANCE, NuvoStatusCodes.getBalanceFromStr(matcher.group(3)));
927                     updateChannelState(zone, CHANNEL_TYPE_LOUDNESS, ONE.equals(matcher.group(4)) ? ON : OFF);
928                 } else {
929                     matcher = ZONE_CFG_PATTERN.matcher(updateData);
930                     // example: ENABLE1,NAME"Great Room",SLAVETO0,GROUP1,SOURCES63,XSRC0,IR1,DND0,LOCKED0,SLAVEEQ0
931                     if (matcher.find()) {
932                         // TODO: utilize other info such as zone name, available sources bitmask, etc.
933
934                         // if this zone is a member of a group (1-4), add the zone's enum to the appropriate group map
935                         if (!ZERO.equals(matcher.group(3))) {
936                             nuvoGroupMap.get(matcher.group(3)).add(zone);
937                         }
938                     } else {
939                         logger.debug("no match on message: {}", updateData);
940                     }
941                 }
942                 break;
943             case TYPE_NN_ALBUM_ART_REQ:
944                 // ignore this update unless openHAB is handling this source
945                 if (nuvoNetSrcMap.get(source).equals(2)) {
946                     logger.debug("Album Art Request for Source: {} - Data: {}", source.getNum(), updateData);
947                     // 0x620FD879,80,80,2,0x00C0C0C0,0,0,0,0,1
948                     String[] albumArtReq = updateData.split(COMMA);
949                     albumArtIds.put(source, Integer.decode(albumArtReq[0]));
950
951                     try {
952                         if (albumArtMap.get(source).length > 1) {
953                             connector.sendCommand(source.getId() + ALBUM_ART_AVAILABLE + albumArtIds.get(source) + COMMA
954                                     + albumArtMap.get(source).length);
955                         } else {
956                             connector.sendCommand(source.getId() + ALBUM_ART_AVAILABLE + ZERO_COMMA);
957                         }
958                     } catch (NuvoException e) {
959                         logger.debug("Error sending ALBUMARTAVAILABLE command for source: {}", source.getNum());
960                     }
961                 }
962                 break;
963             case TYPE_NN_ALBUM_ART_FRAG_REQ:
964                 // ignore this update unless openHAB is handling this source
965                 if (nuvoNetSrcMap.get(source).equals(2)) {
966                     logger.debug("Album Art Fragment Request for Source: {} - Data: {}", source.getNum(), updateData);
967                     // 0x620FD879,0,750 (id, requested offset from start of image, byte length requested)
968                     String[] albumArtFragReq = updateData.split(COMMA);
969                     int requestedId = Integer.decode(albumArtFragReq[0]);
970                     int offset = Integer.parseInt(albumArtFragReq[1]);
971                     int length = Integer.parseInt(albumArtFragReq[2]);
972
973                     if (requestedId == albumArtIds.get(source)) {
974                         byte[] chunk = new byte[length];
975                         byte[] albumArtBytes = albumArtMap.get(source);
976
977                         if (albumArtBytes != null) {
978                             System.arraycopy(albumArtBytes, offset, chunk, 0, length);
979                             final String frag = Base64.getEncoder().encodeToString(chunk);
980                             try {
981                                 connector.sendCommand(source.getId() + ALBUM_ART_FRAG + requestedId + COMMA + offset
982                                         + COMMA + frag.length() + COMMA + frag);
983                             } catch (NuvoException e) {
984                                 logger.debug("Error sending ALBUMARTFRAG command for source: {}, artId: {}",
985                                         source.getNum(), requestedId);
986                             }
987                         }
988                     }
989                 }
990                 break;
991             case TYPE_NN_FAVORITE_REQ:
992                 // ignore this update unless openHAB is handling this source
993                 if (nuvoNetSrcMap.get(source).equals(2)) {
994                     logger.debug("Favorite request for source: {} - favoriteId: {}", source.getNum(), updateData);
995                     try {
996                         int playlistIdx = Integer.parseInt(updateData, 16) - 1000;
997                         updateChannelState(source, CHANNEL_BUTTON_PRESS,
998                                 PLAY_MUSIC_PRESET + favoriteMap.get(source)[playlistIdx]);
999                     } catch (NumberFormatException nfe) {
1000                         logger.debug("Unable to parse favoriteId: {}", updateData);
1001                     }
1002                 }
1003                 break;
1004             default:
1005                 logger.debug("onNewMessageEvent: unhandled event type {}", evt.getType());
1006                 // Return here because receiving an unknown message does not indicate that one can poll
1007                 return;
1008         }
1009
1010         if (isMps4 && pollStatusNeeded) {
1011             pollStatus();
1012         }
1013     }
1014
1015     private void loadMenuConfiguration(NuvoThingConfiguration config) {
1016         StringBuilder menuXml = new StringBuilder("<menu>");
1017
1018         if (!config.menuXmlSrc1.isEmpty()) {
1019             menuXml.append("<source>" + config.menuXmlSrc1 + "</source>");
1020         } else {
1021             menuXml.append("<source/>");
1022         }
1023         if (!config.menuXmlSrc2.isEmpty()) {
1024             menuXml.append("<source>" + config.menuXmlSrc2 + "</source>");
1025         } else {
1026             menuXml.append("<source/>");
1027         }
1028         if (!config.menuXmlSrc3.isEmpty()) {
1029             menuXml.append("<source>" + config.menuXmlSrc3 + "</source>");
1030         } else {
1031             menuXml.append("<source/>");
1032         }
1033         if (!config.menuXmlSrc4.isEmpty()) {
1034             menuXml.append("<source>" + config.menuXmlSrc4 + "</source>");
1035         } else {
1036             menuXml.append("<source/>");
1037         }
1038         if (!config.menuXmlSrc5.isEmpty()) {
1039             menuXml.append("<source>" + config.menuXmlSrc5 + "</source>");
1040         } else {
1041             menuXml.append("<source/>");
1042         }
1043         if (!config.menuXmlSrc6.isEmpty()) {
1044             menuXml.append("<source>" + config.menuXmlSrc6 + "</source>");
1045         } else {
1046             menuXml.append("<source/>");
1047         }
1048         menuXml.append("</menu>");
1049
1050         try {
1051             JAXBContext ctx = JAXBUtils.JAXBCONTEXT_NUVO_MENU;
1052             if (ctx != null) {
1053                 Unmarshaller unmarshaller = ctx.createUnmarshaller();
1054                 if (unmarshaller != null) {
1055                     XMLStreamReader xsr = JAXBUtils.XMLINPUTFACTORY
1056                             .createXMLStreamReader(new StringReader(menuXml.toString()));
1057                     NuvoMenu menu = (NuvoMenu) unmarshaller.unmarshal(xsr);
1058                     if (menu != null) {
1059                         nuvoMenus = menu;
1060                         return;
1061                     }
1062                 }
1063             }
1064             logger.debug("No JAXBContext available to parse Nuvo Menu XML");
1065         } catch (JAXBException | XMLStreamException e) {
1066             logger.warn("Error processing Nuvo Menu XML: {}", e.getLocalizedMessage());
1067         }
1068     }
1069
1070     private void enableNuvonet(boolean showReady) {
1071         if (!this.isAnyOhNuvoNet) {
1072             return;
1073         }
1074
1075         // enable NuvoNet for each source configured as an openHAB NuvoNet source
1076         nuvoNetSrcMap.forEach((source, val) -> {
1077             if (val.equals(2)) {
1078                 try {
1079                     connector.sendCommand(source.getConfigId() + "NUVONET1");
1080                     Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1081                 } catch (NuvoException | InterruptedException e) {
1082                     logger.debug("Error sending SCFG command for source: {}", source.getNum());
1083                 }
1084             }
1085         });
1086
1087         try {
1088             // set '1' flag for each source configured as an MPS4 NuvoNet source or openHAB NuvoNet source
1089             connector.sendCommand("SNUMBERS" + nuvoNetSrcMap.get(NuvoEnum.SOURCE1).compareTo(0) + COMMA
1090                     + nuvoNetSrcMap.get(NuvoEnum.SOURCE2).compareTo(0) + COMMA
1091                     + nuvoNetSrcMap.get(NuvoEnum.SOURCE3).compareTo(0) + COMMA
1092                     + nuvoNetSrcMap.get(NuvoEnum.SOURCE4).compareTo(0) + COMMA
1093                     + nuvoNetSrcMap.get(NuvoEnum.SOURCE5).compareTo(0) + COMMA
1094                     + nuvoNetSrcMap.get(NuvoEnum.SOURCE6).compareTo(0));
1095             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1096         } catch (NuvoException | InterruptedException e) {
1097             logger.debug("Error sending SNUMBERS command");
1098         }
1099
1100         // go though each source and if is openHAB NuvoNet then configure menu, favorites, etc.
1101         nuvoNetSrcMap.forEach((source, val) -> {
1102             if (val.equals(2)) {
1103                 try {
1104                     List<TopMenu> topMenuItems = nuvoMenus.getSource().get(source.getNum() - 1).getTopMenu();
1105
1106                     if (!topMenuItems.isEmpty()) {
1107                         connector.sendCommand(
1108                                 source.getId() + "MENU," + (topMenuItems.size() < 10 ? topMenuItems.size() : 10));
1109                         Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1110
1111                         for (int i = 0; i < (topMenuItems.size() < 10 ? topMenuItems.size() : 10); i++) {
1112                             connector.sendCommand(source.getId() + "MENUITEM" + (i + 1) + ","
1113                                     + (topMenuItems.get(i).getItems().isEmpty() ? ZERO : ONE) + ",0,\""
1114                                     + topMenuItems.get(i).getText() + "\"");
1115                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1116                         }
1117                     }
1118
1119                     String[] favorites = favoriteMap.get(source);
1120                     if (favorites != null) {
1121                         connector.sendCommand(source.getId() + "FAVORITES"
1122                                 + (favorites.length < 20 ? favorites.length : 20) + COMMA
1123                                 + (source.getNum() == 1 ? ONE : ZERO) + COMMA + (source.getNum() == 2 ? ONE : ZERO)
1124                                 + COMMA + (source.getNum() == 3 ? ONE : ZERO) + COMMA
1125                                 + (source.getNum() == 4 ? ONE : ZERO) + COMMA + (source.getNum() == 5 ? ONE : ZERO)
1126                                 + COMMA + (source.getNum() == 6 ? ONE : ZERO));
1127                         Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1128
1129                         for (int i = 0; i < (favorites.length < 20 ? favorites.length : 20); i++) {
1130                             connector.sendCommand(source.getId() + "FAVORITESITEM" + (i + 1000) + ",0,0,\""
1131                                     + favPrefixMap.get(source) + favorites[i] + "\"");
1132                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1133                         }
1134                     }
1135
1136                     if (showReady) {
1137                         connector.sendCommand(source.getId() + "DISPINFOTWO0,0,0,0,0,0,0");
1138                         Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1139                         connector.sendCommand(source.getId() + "DISPLINES0,0,0,\"Ready\",\"\",\"\",\"\"");
1140                         Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1141                     }
1142
1143                 } catch (NuvoException | InterruptedException e) {
1144                     logger.debug("Error configuring NuvoNet for source: {}", source.getNum());
1145                 }
1146             }
1147         });
1148     }
1149
1150     /**
1151      * Schedule the reconnection job
1152      */
1153     private void scheduleReconnectJob() {
1154         logger.debug("Schedule reconnect job");
1155         cancelReconnectJob();
1156         reconnectJob = scheduler.scheduleWithFixedDelay(() -> {
1157             if (!connector.isConnected()) {
1158                 logger.debug("Trying to reconnect...");
1159                 closeConnection();
1160                 if (openConnection()) {
1161                     logger.debug("Reconnected");
1162                     // Polling status will disconnect from MPS4 on reconnect
1163                     if (!isMps4) {
1164                         pollStatus();
1165                     }
1166                     enableNuvonet(true);
1167                 } else {
1168                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Reconnection failed");
1169                     closeConnection();
1170                 }
1171             }
1172         }, 1, RECON_POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
1173     }
1174
1175     /**
1176      * If a ping is not received within ping interval the connection is closed and a reconnect job is scheduled
1177      */
1178     private void schedulePingTimeoutJob() {
1179         if (isMps4) {
1180             logger.debug("Schedule Ping Timeout job");
1181             cancelPingTimeoutJob();
1182             pingJob = scheduler.schedule(() -> {
1183                 closeConnection();
1184                 scheduleReconnectJob();
1185             }, PING_TIMEOUT_SEC, TimeUnit.SECONDS);
1186         } else {
1187             logger.debug("Ping Timeout job not valid for serial connections");
1188         }
1189     }
1190
1191     /**
1192      * Cancel the ping timeout job
1193      */
1194     private void cancelPingTimeoutJob() {
1195         ScheduledFuture<?> pingJob = this.pingJob;
1196         if (pingJob != null) {
1197             pingJob.cancel(true);
1198             this.pingJob = null;
1199         }
1200     }
1201
1202     private void pollStatus() {
1203         pollStatusNeeded = false;
1204         scheduler.submit(() -> {
1205             synchronized (sequenceLock) {
1206                 try {
1207                     connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
1208
1209                     NuvoEnum.VALID_SOURCES.forEach(source -> {
1210                         try {
1211                             connector.sendQuery(source, NuvoCommand.NAME);
1212                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1213                             connector.sendQuery(source, NuvoCommand.DISPINFO);
1214                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1215                             connector.sendQuery(source, NuvoCommand.DISPLINE);
1216                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1217                         } catch (NuvoException | InterruptedException e) {
1218                             logger.debug("Error Querying Source data: {}", e.getMessage());
1219                         }
1220                     });
1221
1222                     // Query all active zones to get their current status and eq configuration
1223                     activeZones.forEach(zoneNum -> {
1224                         try {
1225                             connector.sendQuery(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.STATUS);
1226                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1227                             connector.sendCfgCommand(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.STATUS_QUERY, BLANK);
1228                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1229                             connector.sendCfgCommand(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.EQ_QUERY, BLANK);
1230                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1231                         } catch (NuvoException | InterruptedException e) {
1232                             logger.debug("Error Querying Zone data: {}", e.getMessage());
1233                         }
1234                     });
1235
1236                     List<StateOption> sourceStateOptions = new ArrayList<>();
1237                     sourceLabels.keySet().forEach(key -> {
1238                         sourceStateOptions.add(new StateOption(key, sourceLabels.get(key)));
1239                     });
1240
1241                     // Put the source labels on all active zones
1242                     activeZones.forEach(zoneNum -> {
1243                         stateDescriptionProvider.setStateOptions(
1244                                 new ChannelUID(getThing().getUID(),
1245                                         ZONE.toLowerCase() + zoneNum + CHANNEL_DELIMIT + CHANNEL_TYPE_SOURCE),
1246                                 sourceStateOptions);
1247                     });
1248                 } catch (NuvoException e) {
1249                     logger.debug("Error polling status from Nuvo: {}", e.getMessage());
1250                 }
1251             }
1252         });
1253     }
1254
1255     /**
1256      * Cancel the reconnection job
1257      */
1258     private void cancelReconnectJob() {
1259         ScheduledFuture<?> reconnectJob = this.reconnectJob;
1260         if (reconnectJob != null) {
1261             reconnectJob.cancel(true);
1262             this.reconnectJob = null;
1263         }
1264     }
1265
1266     /**
1267      * Schedule the polling job
1268      */
1269     private void schedulePollingJob() {
1270         cancelPollingJob();
1271
1272         if (isMps4) {
1273             logger.debug("MPS4 doesn't support polling");
1274             return;
1275         } else {
1276             logger.debug("Schedule polling job");
1277         }
1278
1279         // when the Nuvo amp is off, this will keep the connection (esp Serial over IP) alive and detect if the
1280         // connection goes down
1281         pollingJob = scheduler.scheduleWithFixedDelay(() -> {
1282             if (connector.isConnected()) {
1283                 logger.debug("Polling the component for updated status...");
1284
1285                 synchronized (sequenceLock) {
1286                     try {
1287                         connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
1288                     } catch (NuvoException e) {
1289                         logger.debug("Polling error: {}", e.getMessage());
1290                     }
1291
1292                     // if the last event received was more than 1.25 intervals ago,
1293                     // the component is not responding even though the connection is still good
1294                     if ((System.currentTimeMillis() - lastEventReceived) > (POLLING_INTERVAL_SEC * 1.25 * 1000)) {
1295                         logger.debug("Component not responding to status requests");
1296                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1297                                 "Component not responding to status requests");
1298                         closeConnection();
1299                         scheduleReconnectJob();
1300                     }
1301                 }
1302             }
1303         }, INITIAL_POLLING_DELAY_SEC, POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
1304     }
1305
1306     /**
1307      * Cancel the polling job
1308      */
1309     private void cancelPollingJob() {
1310         ScheduledFuture<?> pollingJob = this.pollingJob;
1311         if (pollingJob != null) {
1312             pollingJob.cancel(true);
1313             this.pollingJob = null;
1314         }
1315     }
1316
1317     /**
1318      * Schedule the clock sync job
1319      */
1320     private void scheduleClockSyncJob() {
1321         logger.debug("Schedule clock sync job");
1322         cancelClockSyncJob();
1323         clockSyncJob = scheduler.scheduleWithFixedDelay(() -> {
1324             if (this.isGConcerto) {
1325                 try {
1326                     SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy,MM,dd,HH,mm");
1327                     connector.sendCommand(NuvoCommand.CFGTIME.getValue() + dateFormat.format(new Date()));
1328                 } catch (NuvoException e) {
1329                     logger.debug("Error syncing clock: {}", e.getMessage());
1330                 }
1331             } else {
1332                 this.cancelClockSyncJob();
1333             }
1334         }, INITIAL_CLOCK_SYNC_DELAY_SEC, CLOCK_SYNC_INTERVAL_SEC, TimeUnit.SECONDS);
1335     }
1336
1337     /**
1338      * Cancel the clock sync job
1339      */
1340     private void cancelClockSyncJob() {
1341         ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
1342         if (clockSyncJob != null) {
1343             clockSyncJob.cancel(true);
1344             this.clockSyncJob = null;
1345         }
1346     }
1347
1348     /**
1349      * Update the state of a channel (original method signature)
1350      *
1351      * @param target the channel group
1352      * @param channelType the channel group item
1353      * @param value the value to be updated
1354      */
1355     private void updateChannelState(NuvoEnum target, String channelType, String value) {
1356         updateChannelState(target, channelType, value, NO_ART);
1357     }
1358
1359     /**
1360      * Update the state of a channel (overloaded method to handle album_art channel)
1361      *
1362      * @param target the channel group
1363      * @param channelType the channel group item
1364      * @param value the value to be updated
1365      * @param bytes the byte[] to load into the Image channel
1366      */
1367     private void updateChannelState(NuvoEnum target, String channelType, String value, byte[] bytes) {
1368         String channel = target.name().toLowerCase() + CHANNEL_DELIMIT + channelType;
1369
1370         if (!isLinked(channel)) {
1371             return;
1372         }
1373
1374         State state = UnDefType.UNDEF;
1375
1376         if (UNDEF.equals(value)) {
1377             updateState(channel, state);
1378             return;
1379         }
1380
1381         switch (channelType) {
1382             case CHANNEL_TYPE_POWER:
1383             case CHANNEL_TYPE_MUTE:
1384             case CHANNEL_TYPE_DND:
1385             case CHANNEL_TYPE_PARTY:
1386             case CHANNEL_TYPE_ALLMUTE:
1387             case CHANNEL_TYPE_PAGE:
1388             case CHANNEL_TYPE_LOUDNESS:
1389                 state = OnOffType.from(ON.equals(value));
1390                 break;
1391             case CHANNEL_TYPE_LOCK:
1392                 state = ON.equals(value) ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
1393                 break;
1394             case CHANNEL_TYPE_SOURCE:
1395             case CHANNEL_TYPE_TREBLE:
1396             case CHANNEL_TYPE_BASS:
1397             case CHANNEL_TYPE_BALANCE:
1398                 state = new DecimalType(value);
1399                 break;
1400             case CHANNEL_TYPE_VOLUME:
1401                 int volume = Integer.parseInt(value);
1402                 long volumePct = Math
1403                         .round((double) (MAX_VOLUME - volume) / (double) (MAX_VOLUME - MIN_VOLUME) * 100.0);
1404                 state = new PercentType(BigDecimal.valueOf(volumePct));
1405                 break;
1406             case CHANNEL_TYPE_BUTTONPRESS:
1407             case CHANNEL_DISPLAY_LINE1:
1408             case CHANNEL_DISPLAY_LINE2:
1409             case CHANNEL_DISPLAY_LINE3:
1410             case CHANNEL_DISPLAY_LINE4:
1411             case CHANNEL_BUTTON_PRESS:
1412                 state = new StringType(value);
1413                 break;
1414             case CHANNEL_PLAY_MODE:
1415                 state = new StringType(NuvoStatusCodes.PLAY_MODE.get(value));
1416                 break;
1417             case CHANNEL_TRACK_LENGTH:
1418             case CHANNEL_TRACK_POSITION:
1419                 state = new QuantityType<>(Integer.parseInt(value) / 10, NuvoHandler.API_SECOND_UNIT);
1420                 break;
1421             case CHANNEL_ALBUM_ART:
1422                 state = new RawType(bytes, "image/jpeg");
1423                 break;
1424             default:
1425                 break;
1426         }
1427         updateState(channel, state);
1428     }
1429
1430     /**
1431      * For grouped zones, update the source channel for all group members
1432      *
1433      * @param zoneEnum the zone where the source was changed
1434      * @param srcId the new source number that was selected
1435      */
1436     private void updateSrcForZoneGroup(NuvoEnum zoneEnum, String srcId) {
1437         // check if this zone is in a group, if so update the other group member's selected source
1438         nuvoGroupMap.forEach((groupId, groupZones) -> {
1439             if (groupZones.contains(zoneEnum)) {
1440                 groupZones.forEach(z -> {
1441                     if (!zoneEnum.equals(z)) {
1442                         updateChannelState(z, CHANNEL_TYPE_SOURCE, srcId);
1443                     }
1444                 });
1445             }
1446         });
1447     }
1448
1449     /**
1450      * Handle a button press from a UI Player item
1451      *
1452      * @param target the nuvo zone to receive the command
1453      * @param command the button press command to send to the zone
1454      */
1455     private void handleControlCommand(NuvoEnum target, Command command) throws NuvoException {
1456         if (command instanceof PlayPauseType) {
1457             connector.sendCommand(target, NuvoCommand.PLAYPAUSE);
1458         } else if (command instanceof NextPreviousType) {
1459             if (command == NextPreviousType.NEXT) {
1460                 connector.sendCommand(target, NuvoCommand.NEXT);
1461             } else if (command == NextPreviousType.PREVIOUS) {
1462                 connector.sendCommand(target, NuvoCommand.PREV);
1463             }
1464         } else {
1465             logger.warn("Unknown control command: {}", command);
1466         }
1467     }
1468
1469     /**
1470      * Scrapes the MPS4's json api to retrieve the currently playing media's album art
1471      *
1472      * @param source the source that should be queried to load the current album art
1473      */
1474     private void getMps4AlbumArt(NuvoEnum source) {
1475         final String clientId = UUID.randomUUID().toString();
1476
1477         // try to get cached source instance
1478         String instance = sourceInstanceMap.get(source);
1479
1480         // if not found, need to retrieve from the api, once found these calls will be skipped
1481         if (instance == null) {
1482             // find which zone is using this source
1483             NuvoEnum zone = sourceZoneMap.get(source);
1484
1485             if (zone == null) {
1486                 logger.debug("Unable to determine zone that is using source {}", source);
1487                 return;
1488             } else {
1489                 try {
1490                     final String json = getMcsJson(String.format(GET_MCS_INSTANCE, mps4Host, zone.getNum(), clientId),
1491                             clientId);
1492
1493                     Matcher matcher = MCS_INSTANCE_PATTERN.matcher(json);
1494                     if (matcher.find()) {
1495                         instance = matcher.group(1);
1496                         sourceInstanceMap.put(source, instance);
1497                         logger.debug("Found instance '{}' for source {}", instance, source);
1498                     } else {
1499                         logger.debug("No instance match found for json: {}", json);
1500                         return;
1501                     }
1502                 } catch (TimeoutException | ExecutionException e) {
1503                     logger.debug("Failed getting instance name", e);
1504                     return;
1505                 } catch (InterruptedException e) {
1506                     logger.debug("InterruptedException getting instance name", e);
1507                     Thread.currentThread().interrupt();
1508                     return;
1509                 }
1510             }
1511         }
1512
1513         try {
1514             logger.debug("Using MCS instance '{}' for source {}", instance, source);
1515             final String json = getMcsJson(String.format(GET_MCS_STATUS, mps4Host, instance, clientId), clientId);
1516
1517             if (json.contains("\"name\":\"PlayState\",\"value\":3}")) {
1518                 Matcher matcher = ART_GUID_PATTERN.matcher(json);
1519                 if (matcher.find()) {
1520                     final String nowPlayingGuid = matcher.group(1);
1521
1522                     // If streaming (NowPlayingType=Radio), always retrieve because the same NowPlayingGuid can
1523                     // get a different image written to it by Gracenote when the track changes
1524                     if (!mps4ArtGuids.get(source).equals(nowPlayingGuid)
1525                             || json.contains("NowPlayingType\",\"value\":\"Radio\"}")) {
1526                         ContentResponse artResponse = httpClient
1527                                 .newRequest(String.format(GET_MCS_ART, mps4Host, nowPlayingGuid, instance)).method(GET)
1528                                 .timeout(10, TimeUnit.SECONDS).send();
1529
1530                         if (artResponse.getStatus() == OK_200) {
1531                             logger.debug("Retrieved album art for guid: {}", nowPlayingGuid);
1532                             updateChannelState(source, CHANNEL_ALBUM_ART, BLANK, artResponse.getContent());
1533                             mps4ArtGuids.put(source, nowPlayingGuid);
1534                         }
1535                     } else {
1536                         logger.debug("Album art has not changed, guid: {}", nowPlayingGuid);
1537                     }
1538                 } else {
1539                     logger.debug("NowPlayingGuid not found");
1540                 }
1541             } else {
1542                 logger.debug("PlayState not valid");
1543             }
1544         } catch (TimeoutException | ExecutionException e) {
1545             logger.debug("Failed getting album art", e);
1546             updateChannelState(source, CHANNEL_ALBUM_ART, UNDEF);
1547             mps4ArtGuids.put(source, BLANK);
1548         } catch (InterruptedException e) {
1549             logger.debug("InterruptedException getting album art", e);
1550             updateChannelState(source, CHANNEL_ALBUM_ART, UNDEF);
1551             mps4ArtGuids.put(source, BLANK);
1552             Thread.currentThread().interrupt();
1553         }
1554     }
1555
1556     /**
1557      * Used by getMps4AlbumArt to abstract retrieval of status json from MCS
1558      *
1559      * @param commandUrl the url with the embedded commands to send to MCS
1560      * @param clientId the current clientId
1561      * @return string json result from the command executed
1562      *
1563      * @throws InterruptedException
1564      * @throws TimeoutException
1565      * @throws ExecutionException
1566      */
1567     private String getMcsJson(String commandUrl, String clientId)
1568             throws InterruptedException, TimeoutException, ExecutionException {
1569         ContentResponse commandResp = httpClient.newRequest(commandUrl).method(GET).timeout(10, TimeUnit.SECONDS)
1570                 .send();
1571
1572         if (commandResp.getStatus() == OK_200) {
1573             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1574             ContentResponse jsonResp = httpClient.newRequest(String.format(GET_MCS_JSON, mps4Host, clientId))
1575                     .method(GET).timeout(10, TimeUnit.SECONDS).send();
1576             if (jsonResp.getStatus() == OK_200) {
1577                 return jsonResp.getContentAsString();
1578             } else {
1579                 logger.debug("Got error response {} when getting json from MCS", commandResp.getStatus());
1580                 return BLANK;
1581             }
1582         }
1583         logger.debug("Got error response {} when sending json command url: {}", commandResp.getStatus(), commandUrl);
1584         return BLANK;
1585     }
1586 }