]> git.basschouten.com Git - openhab-addons.git/blob
413418dbd34a98e3c92a4d331cbb1bec2eb8cd28
[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                         break;
648                     case CHANNEL_SOURCE_MENU:
649                         if (command instanceof StringType) {
650                             updateChannelState(target, CHANNEL_BUTTON_PRESS, command.toString());
651                         }
652                 }
653             } catch (NuvoException e) {
654                 logger.warn("Command {} from channel {} failed: {}", command, channel, e.getMessage());
655                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Sending command failed");
656                 closeConnection();
657                 scheduleReconnectJob();
658             }
659         }
660     }
661
662     /**
663      * Open the connection with the Nuvo device
664      *
665      * @return true if the connection is opened successfully or false if not
666      */
667     private synchronized boolean openConnection() {
668         connector.addEventListener(this);
669         try {
670             connector.open();
671         } catch (NuvoException e) {
672             logger.debug("openConnection() failed: {}", e.getMessage());
673         }
674         logger.debug("openConnection(): {}", connector.isConnected() ? "connected" : "disconnected");
675         return connector.isConnected();
676     }
677
678     /**
679      * Close the connection with the Nuvo device
680      */
681     private synchronized void closeConnection() {
682         if (connector.isConnected()) {
683             connector.close();
684             connector.removeEventListener(this);
685             pollStatusNeeded = true;
686             logger.debug("closeConnection(): disconnected");
687         }
688     }
689
690     /**
691      * Handle an event received from the Nuvo device
692      *
693      * @param evt the event to process
694      */
695     @Override
696     public void onNewMessageEvent(NuvoMessageEvent evt) {
697         logger.debug("onNewMessageEvent: zone {}, source {}, value {}", evt.getZone(), evt.getSrc(), evt.getValue());
698         lastEventReceived = System.currentTimeMillis();
699
700         final NuvoEnum zone = !evt.getZone().isEmpty() ? NuvoEnum.valueOf(ZONE + evt.getZone()) : NuvoEnum.SYSTEM;
701         final NuvoEnum source = !evt.getSrc().isEmpty() ? NuvoEnum.valueOf(SOURCE + evt.getSrc()) : NuvoEnum.SYSTEM;
702         final String sourceZone = source.getId() + zone.getId();
703         final String updateData = evt.getValue().trim();
704
705         if (this.getThing().getStatus() != ThingStatus.ONLINE) {
706             updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, this.versionString);
707         }
708
709         switch (evt.getType()) {
710             case TYPE_VERSION:
711                 this.versionString = updateData;
712                 // Determine if we are a Grand Concerto or not
713                 if (this.versionString.contains(GC_STR)) {
714                     logger.debug("Grand Concerto detected");
715                     this.isGConcerto = true;
716                     connector.setEssentia(false);
717                 } else {
718                     logger.debug("Grand Concerto not detected");
719                 }
720                 break;
721             case TYPE_RESTART:
722                 logger.debug("Restart message received; re-sending initialization messages");
723                 enableNuvonet(false);
724                 return;
725             case TYPE_PING:
726                 logger.debug("Ping message received- rescheduling ping timeout");
727                 schedulePingTimeoutJob();
728                 // Return here because receiving a ping does not indicate that one can poll
729                 return;
730             case TYPE_ALLOFF:
731                 activeZones.forEach(zoneNum -> {
732                     updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_POWER, OFF);
733                 });
734
735                 // Publish the ALLOFF event to all button channels for awareness in source rules
736                 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, ZERO + COMMA + ALLOFF);
737                 NuvoEnum.VALID_SOURCES.forEach(src -> {
738                     updateChannelState(src, CHANNEL_BUTTON_PRESS, ALLOFF);
739                 });
740
741                 break;
742             case TYPE_ALLMUTE:
743                 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_ALLMUTE, ONE.equals(updateData) ? ON : OFF);
744                 activeZones.forEach(zoneNum -> {
745                     updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_MUTE,
746                             ONE.equals(updateData) ? ON : OFF);
747                 });
748                 break;
749             case TYPE_PAGE:
750                 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_PAGE, ONE.equals(updateData) ? ON : OFF);
751                 break;
752             case TYPE_SOURCE_UPDATE:
753                 logger.debug("Source update: Source: {} - Value: {}", source.getNum(), updateData);
754
755                 if (updateData.contains(DISPLINE)) {
756                     // example: DISPLINE2,"Play My Song (Featuring Dee Ajayi)"
757                     Matcher matcher = DISP_PATTERN.matcher(updateData);
758                     if (matcher.find()) {
759                         updateChannelState(source, CHANNEL_DISPLAY_LINE + matcher.group(1), matcher.group(2));
760                     } else {
761                         logger.debug("no match on message: {}", updateData);
762                     }
763                 } else if (updateData.contains(DISPINFO)) {
764                     // example: DISPINFO,DUR0,POS70,STATUS2 (DUR and POS are expressed in tenths of a second)
765                     // 6 places(tenths of a second)-> max 999,999 /10/60/60/24 = 1.15 days
766                     Matcher matcher = DISP_INFO_PATTERN.matcher(updateData);
767                     if (matcher.find()) {
768                         updateChannelState(source, CHANNEL_TRACK_LENGTH, matcher.group(1));
769                         updateChannelState(source, CHANNEL_TRACK_POSITION, matcher.group(2));
770                         updateChannelState(source, CHANNEL_PLAY_MODE, matcher.group(3));
771
772                         // if this is an MPS4 source, the following retrieves album art when the source is playing
773                         if (nuvoNetSrcMap.get(source) == 1
774                                 && isLinked(source.name().toLowerCase() + CHANNEL_DELIMIT + CHANNEL_ALBUM_ART)) {
775                             if (MPS4_PLAYING_MODES.contains(matcher.group(3))) {
776                                 logger.debug("DISPINFO update, trying to get album art");
777                                 getMps4AlbumArt(source);
778                             } else if (MPS4_IDLE_MODES.contains(matcher.group(3)) && ZERO.equals(matcher.group(1))) {
779                                 // clear album art channel for this source
780                                 logger.debug("DISPINFO update- not playing; clearing art, mode: {}", matcher.group(3));
781                                 updateChannelState(source, CHANNEL_ALBUM_ART, UNDEF);
782                                 mps4ArtGuids.put(source, BLANK);
783                             }
784                         }
785                     } else {
786                         logger.debug("no match on message: {}", updateData);
787                     }
788                 } else if (updateData.contains(NAME_QUOTE)) {
789                     // example: NAME"Ipod"
790                     String name = updateData.split("\"")[1];
791                     sourceLabels.put(String.valueOf(source.getNum()), name);
792                 }
793                 break;
794             case TYPE_ZONE_UPDATE:
795                 logger.debug("Zone update: Zone: {} - Value: {}", zone.getNum(), updateData);
796                 // example : OFF
797                 // or: ON,SRC3,VOL63,DND0,LOCK0
798                 // or: ON,SRC3,MUTE,DND0,LOCK0
799
800                 if (OFF.equals(updateData)) {
801                     updateChannelState(zone, CHANNEL_TYPE_POWER, OFF);
802                     updateChannelState(zone, CHANNEL_TYPE_SOURCE, UNDEF);
803                 } else {
804                     Matcher matcher = ZONE_PATTERN.matcher(updateData);
805                     if (matcher.find()) {
806                         updateChannelState(zone, CHANNEL_TYPE_POWER, ON);
807                         updateChannelState(zone, CHANNEL_TYPE_SOURCE, matcher.group(1));
808                         sourceZoneMap.put(NuvoEnum.valueOf(SOURCE + matcher.group(1)), zone);
809
810                         // update the other group member's selected source
811                         updateSrcForZoneGroup(zone, matcher.group(1));
812
813                         if (MUTE.equals(matcher.group(2))) {
814                             updateChannelState(zone, CHANNEL_TYPE_MUTE, ON);
815                         } else {
816                             updateChannelState(zone, CHANNEL_TYPE_MUTE, NuvoCommand.OFF.getValue());
817                             updateChannelState(zone, CHANNEL_TYPE_VOLUME, matcher.group(2).replace(VOL, BLANK));
818                         }
819
820                         updateChannelState(zone, CHANNEL_TYPE_DND, ONE.equals(matcher.group(3)) ? ON : OFF);
821                         updateChannelState(zone, CHANNEL_TYPE_LOCK, ONE.equals(matcher.group(4)) ? ON : OFF);
822                     } else {
823                         logger.debug("no match on message: {}", updateData);
824                     }
825                 }
826                 break;
827             case TYPE_ZONE_SOURCE_BUTTON:
828                 logger.debug("Source Button pressed: Source: {} - Button: {}", source.getNum(), updateData);
829                 updateChannelState(source, CHANNEL_BUTTON_PRESS, updateData);
830                 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, zone.getNum() + COMMA + updateData);
831                 break;
832             case TYPE_NN_BUTTON:
833                 String buttonAction = NuvoStatusCodes.BUTTON_CODE.get(updateData);
834
835                 if (buttonAction != null) {
836                     logger.debug("NuvoNet Source Button pressed: Source: {} - Button: {}", source.getNum(),
837                             buttonAction);
838                     updateChannelState(source, CHANNEL_BUTTON_PRESS, buttonAction);
839                     updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, zone.getNum() + COMMA + buttonAction);
840                 } else {
841                     logger.debug("NuvoNet Source Button pressed: Source: {} - Unknown button code: {}", source.getNum(),
842                             updateData);
843                     updateChannelState(source, CHANNEL_BUTTON_PRESS, updateData);
844                     updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, zone.getNum() + COMMA + updateData);
845                 }
846                 break;
847             case TYPE_NN_MENU_ITEM_SELECTED:
848                 // ignore this update unless openHAB is handling this source
849                 if (nuvoNetSrcMap.get(source).equals(2)) {
850                     String[] updateDataSplit = updateData.split(COMMA);
851                     String menuId = updateDataSplit[0];
852                     int menuItemIdx = Integer.parseInt(updateDataSplit[1]) - 1;
853
854                     boolean exitMenu = false;
855                     if ("0xFFFFFFFF".equals(menuId)) {
856                         TopMenu topMenuItem = nuvoMenus.getSource().get(source.getNum() - 1).getTopMenu()
857                                 .get(menuItemIdx);
858                         logger.debug("Top Menu item selected: Source: {} - Menu Item: {}", source.getNum(),
859                                 topMenuItem.getText());
860                         updateChannelState(source, CHANNEL_BUTTON_PRESS, topMenuItem.getText());
861                         updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS,
862                                 zone.getNum() + COMMA + topMenuItem.getText());
863
864                         List<String> subMenuItems = topMenuItem.getItems();
865
866                         if (subMenuItems.isEmpty()) {
867                             exitMenu = true;
868                         } else {
869                             // send submenu (maximum of 20 items)
870                             int subMenuSize = subMenuItems.size() < 20 ? subMenuItems.size() : 20;
871                             try {
872                                 connector.sendCommand(sourceZone + "MENU" + (menuItemIdx + 11) + ",0,0," + subMenuSize
873                                         + ",0,0," + subMenuSize + ",\"" + topMenuItem.getText() + "\"");
874                                 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
875
876                                 for (int i = 0; i < subMenuSize; i++) {
877                                     connector.sendCommand(
878                                             sourceZone + "MENUITEM" + (i + 1) + ",0,0,\"" + subMenuItems.get(i) + "\"");
879                                 }
880                             } catch (NuvoException | InterruptedException e) {
881                                 logger.debug("Error sending sub menu to {}", sourceZone);
882                             }
883                         }
884                     } else {
885                         // a sub menu item was selected
886                         TopMenu topMenuItem = nuvoMenus.getSource().get(source.getNum() - 1).getTopMenu()
887                                 .get(Integer.decode(menuId) - 11);
888                         String subMenuItem = topMenuItem.getItems().get(menuItemIdx);
889
890                         logger.debug("Sub Menu item selected: Source: {} - Menu Item: {}", source.getNum(),
891                                 topMenuItem.getText() + "|" + subMenuItem);
892                         updateChannelState(source, CHANNEL_BUTTON_PRESS, topMenuItem.getText() + "|" + subMenuItem);
893                         updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS,
894                                 zone.getNum() + COMMA + topMenuItem.getText() + "|" + subMenuItem);
895                         exitMenu = true;
896                     }
897
898                     if (exitMenu) {
899                         try {
900                             // tell the zone to exit the menu
901                             connector.sendCommand(sourceZone + "MENU0,0,0,0,0,0,0,\"\"");
902                         } catch (NuvoException e) {
903                             logger.debug("Error sending exit menu command to {}", sourceZone);
904                         }
905                     }
906                 }
907                 break;
908             case TYPE_NN_MENUREQ:
909                 // ignore this update unless openHAB is handling this source
910                 if (nuvoNetSrcMap.get(source).equals(2)) {
911                     logger.debug("Menu Request: Source: {} - Value: {}", source.getNum(), updateData);
912                     // For now we only support one level deep menus. If second field is '1', indicates go back to main
913                     // menu.
914                     String[] menuDataSplit = updateData.split(COMMA);
915                     if (menuDataSplit.length > 2 && ONE.equals(menuDataSplit[1])) {
916                         try {
917                             connector.sendCommand(sourceZone + "MENU0xFFFFFFFF,0,0,0,0,0,0,\"\"");
918                         } catch (NuvoException e) {
919                             logger.debug("Error sending main menu command to {}", sourceZone);
920                         }
921                     }
922                 }
923                 break;
924             case TYPE_ZONE_CONFIG:
925                 logger.debug("Zone Configuration: Zone: {} - Value: {}", zone.getNum(), updateData);
926                 // example: BASS1,TREB-2,BALR2,LOUDCMP1
927                 Matcher matcher = ZONE_CFG_EQ_PATTERN.matcher(updateData);
928                 if (matcher.find()) {
929                     updateChannelState(zone, CHANNEL_TYPE_BASS, matcher.group(1));
930                     updateChannelState(zone, CHANNEL_TYPE_TREBLE, matcher.group(2));
931                     updateChannelState(zone, CHANNEL_TYPE_BALANCE, NuvoStatusCodes.getBalanceFromStr(matcher.group(3)));
932                     updateChannelState(zone, CHANNEL_TYPE_LOUDNESS, ONE.equals(matcher.group(4)) ? ON : OFF);
933                 } else {
934                     matcher = ZONE_CFG_PATTERN.matcher(updateData);
935                     // example: ENABLE1,NAME"Great Room",SLAVETO0,GROUP1,SOURCES63,XSRC0,IR1,DND0,LOCKED0,SLAVEEQ0
936                     if (matcher.find()) {
937                         // TODO: utilize other info such as zone name, available sources bitmask, etc.
938
939                         // if this zone is a member of a group (1-4), add the zone's enum to the appropriate group map
940                         if (!ZERO.equals(matcher.group(3))) {
941                             nuvoGroupMap.get(matcher.group(3)).add(zone);
942                         }
943                     } else {
944                         logger.debug("no match on message: {}", updateData);
945                     }
946                 }
947                 break;
948             case TYPE_NN_ALBUM_ART_REQ:
949                 // ignore this update unless openHAB is handling this source
950                 if (nuvoNetSrcMap.get(source).equals(2)) {
951                     logger.debug("Album Art Request for Source: {} - Data: {}", source.getNum(), updateData);
952                     // 0x620FD879,80,80,2,0x00C0C0C0,0,0,0,0,1
953                     String[] albumArtReq = updateData.split(COMMA);
954                     albumArtIds.put(source, Integer.decode(albumArtReq[0]));
955
956                     try {
957                         if (albumArtMap.get(source).length > 1) {
958                             connector.sendCommand(source.getId() + ALBUM_ART_AVAILABLE + albumArtIds.get(source) + COMMA
959                                     + albumArtMap.get(source).length);
960                         } else {
961                             connector.sendCommand(source.getId() + ALBUM_ART_AVAILABLE + ZERO_COMMA);
962                         }
963                     } catch (NuvoException e) {
964                         logger.debug("Error sending ALBUMARTAVAILABLE command for source: {}", source.getNum());
965                     }
966                 }
967                 break;
968             case TYPE_NN_ALBUM_ART_FRAG_REQ:
969                 // ignore this update unless openHAB is handling this source
970                 if (nuvoNetSrcMap.get(source).equals(2)) {
971                     logger.debug("Album Art Fragment Request for Source: {} - Data: {}", source.getNum(), updateData);
972                     // 0x620FD879,0,750 (id, requested offset from start of image, byte length requested)
973                     String[] albumArtFragReq = updateData.split(COMMA);
974                     int requestedId = Integer.decode(albumArtFragReq[0]);
975                     int offset = Integer.parseInt(albumArtFragReq[1]);
976                     int length = Integer.parseInt(albumArtFragReq[2]);
977
978                     if (requestedId == albumArtIds.get(source)) {
979                         byte[] chunk = new byte[length];
980                         byte[] albumArtBytes = albumArtMap.get(source);
981
982                         if (albumArtBytes != null) {
983                             System.arraycopy(albumArtBytes, offset, chunk, 0, length);
984                             final String frag = Base64.getEncoder().encodeToString(chunk);
985                             try {
986                                 connector.sendCommand(source.getId() + ALBUM_ART_FRAG + requestedId + COMMA + offset
987                                         + COMMA + frag.length() + COMMA + frag);
988                             } catch (NuvoException e) {
989                                 logger.debug("Error sending ALBUMARTFRAG command for source: {}, artId: {}",
990                                         source.getNum(), requestedId);
991                             }
992                         }
993                     }
994                 }
995                 break;
996             case TYPE_NN_FAVORITE_REQ:
997                 // ignore this update unless openHAB is handling this source
998                 if (nuvoNetSrcMap.get(source).equals(2)) {
999                     logger.debug("Favorite request for source: {} - favoriteId: {}", source.getNum(), updateData);
1000                     try {
1001                         int playlistIdx = Integer.parseInt(updateData, 16) - 1000;
1002                         updateChannelState(source, CHANNEL_BUTTON_PRESS,
1003                                 PLAY_MUSIC_PRESET + favoriteMap.get(source)[playlistIdx]);
1004                     } catch (NumberFormatException nfe) {
1005                         logger.debug("Unable to parse favoriteId: {}", updateData);
1006                     }
1007                 }
1008                 break;
1009             default:
1010                 logger.debug("onNewMessageEvent: unhandled event type {}", evt.getType());
1011                 // Return here because receiving an unknown message does not indicate that one can poll
1012                 return;
1013         }
1014
1015         if (isMps4 && pollStatusNeeded) {
1016             pollStatus();
1017         }
1018     }
1019
1020     private void loadMenuConfiguration(NuvoThingConfiguration config) {
1021         StringBuilder menuXml = new StringBuilder("<menu>");
1022
1023         if (!config.menuXmlSrc1.isEmpty()) {
1024             menuXml.append("<source>" + config.menuXmlSrc1 + "</source>");
1025         } else {
1026             menuXml.append("<source/>");
1027         }
1028         if (!config.menuXmlSrc2.isEmpty()) {
1029             menuXml.append("<source>" + config.menuXmlSrc2 + "</source>");
1030         } else {
1031             menuXml.append("<source/>");
1032         }
1033         if (!config.menuXmlSrc3.isEmpty()) {
1034             menuXml.append("<source>" + config.menuXmlSrc3 + "</source>");
1035         } else {
1036             menuXml.append("<source/>");
1037         }
1038         if (!config.menuXmlSrc4.isEmpty()) {
1039             menuXml.append("<source>" + config.menuXmlSrc4 + "</source>");
1040         } else {
1041             menuXml.append("<source/>");
1042         }
1043         if (!config.menuXmlSrc5.isEmpty()) {
1044             menuXml.append("<source>" + config.menuXmlSrc5 + "</source>");
1045         } else {
1046             menuXml.append("<source/>");
1047         }
1048         if (!config.menuXmlSrc6.isEmpty()) {
1049             menuXml.append("<source>" + config.menuXmlSrc6 + "</source>");
1050         } else {
1051             menuXml.append("<source/>");
1052         }
1053         menuXml.append("</menu>");
1054
1055         try {
1056             JAXBContext ctx = JAXBUtils.JAXBCONTEXT_NUVO_MENU;
1057             if (ctx != null) {
1058                 Unmarshaller unmarshaller = ctx.createUnmarshaller();
1059                 if (unmarshaller != null) {
1060                     XMLStreamReader xsr = JAXBUtils.XMLINPUTFACTORY
1061                             .createXMLStreamReader(new StringReader(menuXml.toString()));
1062                     NuvoMenu menu = (NuvoMenu) unmarshaller.unmarshal(xsr);
1063                     if (menu != null) {
1064                         nuvoMenus = menu;
1065                         return;
1066                     }
1067                 }
1068             }
1069             logger.debug("No JAXBContext available to parse Nuvo Menu XML");
1070         } catch (JAXBException | XMLStreamException e) {
1071             logger.warn("Error processing Nuvo Menu XML: {}", e.getLocalizedMessage());
1072         }
1073     }
1074
1075     private void enableNuvonet(boolean showReady) {
1076         if (!this.isAnyOhNuvoNet) {
1077             return;
1078         }
1079
1080         // enable NuvoNet for each source configured as an openHAB NuvoNet source
1081         nuvoNetSrcMap.forEach((source, val) -> {
1082             if (val.equals(2)) {
1083                 try {
1084                     connector.sendCommand(source.getConfigId() + "NUVONET1");
1085                     Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1086                 } catch (NuvoException | InterruptedException e) {
1087                     logger.debug("Error sending SCFG command for source: {}", source.getNum());
1088                 }
1089             }
1090         });
1091
1092         try {
1093             // set '1' flag for each source configured as an MPS4 NuvoNet source or openHAB NuvoNet source
1094             connector.sendCommand("SNUMBERS" + nuvoNetSrcMap.get(NuvoEnum.SOURCE1).compareTo(0) + COMMA
1095                     + nuvoNetSrcMap.get(NuvoEnum.SOURCE2).compareTo(0) + COMMA
1096                     + nuvoNetSrcMap.get(NuvoEnum.SOURCE3).compareTo(0) + COMMA
1097                     + nuvoNetSrcMap.get(NuvoEnum.SOURCE4).compareTo(0) + COMMA
1098                     + nuvoNetSrcMap.get(NuvoEnum.SOURCE5).compareTo(0) + COMMA
1099                     + nuvoNetSrcMap.get(NuvoEnum.SOURCE6).compareTo(0));
1100             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1101         } catch (NuvoException | InterruptedException e) {
1102             logger.debug("Error sending SNUMBERS command");
1103         }
1104
1105         // go though each source and if is openHAB NuvoNet then configure menu, favorites, etc.
1106         nuvoNetSrcMap.forEach((source, val) -> {
1107             if (val.equals(2)) {
1108                 try {
1109                     List<TopMenu> topMenuItems = nuvoMenus.getSource().get(source.getNum() - 1).getTopMenu();
1110
1111                     if (!topMenuItems.isEmpty()) {
1112                         connector.sendCommand(
1113                                 source.getId() + "MENU," + (topMenuItems.size() < 10 ? topMenuItems.size() : 10));
1114                         Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1115
1116                         for (int i = 0; i < (topMenuItems.size() < 10 ? topMenuItems.size() : 10); i++) {
1117                             connector.sendCommand(source.getId() + "MENUITEM" + (i + 1) + ","
1118                                     + (topMenuItems.get(i).getItems().isEmpty() ? ZERO : ONE) + ",0,\""
1119                                     + topMenuItems.get(i).getText() + "\"");
1120                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1121                         }
1122
1123                         // Build a State options selection that represents this source's custom menu
1124                         List<StateOption> sourceMenuStateOptions = new ArrayList<>();
1125                         topMenuItems.forEach(topItem -> {
1126                             sourceMenuStateOptions.add(new StateOption(topItem.getText(), topItem.getText()));
1127                             topItem.getItems().forEach(subItem -> sourceMenuStateOptions
1128                                     .add(new StateOption(topItem.getText() + "|" + subItem, "-> " + subItem)));
1129                         });
1130                         stateDescriptionProvider.setStateOptions(
1131                                 new ChannelUID(getThing().getUID(),
1132                                         source.name().toLowerCase() + CHANNEL_DELIMIT + CHANNEL_SOURCE_MENU),
1133                                 sourceMenuStateOptions);
1134                     }
1135
1136                     String[] favorites = favoriteMap.get(source);
1137                     if (favorites != null) {
1138                         connector.sendCommand(source.getId() + "FAVORITES"
1139                                 + (favorites.length < 20 ? favorites.length : 20) + COMMA
1140                                 + (source.getNum() == 1 ? ONE : ZERO) + COMMA + (source.getNum() == 2 ? ONE : ZERO)
1141                                 + COMMA + (source.getNum() == 3 ? ONE : ZERO) + COMMA
1142                                 + (source.getNum() == 4 ? ONE : ZERO) + COMMA + (source.getNum() == 5 ? ONE : ZERO)
1143                                 + COMMA + (source.getNum() == 6 ? ONE : ZERO));
1144                         Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1145
1146                         for (int i = 0; i < (favorites.length < 20 ? favorites.length : 20); i++) {
1147                             connector.sendCommand(source.getId() + "FAVORITESITEM" + (i + 1000) + ",0,0,\""
1148                                     + favPrefixMap.get(source) + favorites[i] + "\"");
1149                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1150                         }
1151                     }
1152
1153                     if (showReady) {
1154                         connector.sendCommand(source.getId() + "DISPINFOTWO0,0,0,0,0,0,0");
1155                         Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1156                         connector.sendCommand(source.getId() + "DISPLINES0,0,0,\"Ready\",\"\",\"\",\"\"");
1157                         Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1158                     }
1159
1160                 } catch (NuvoException | InterruptedException e) {
1161                     logger.debug("Error configuring NuvoNet for source: {}", source.getNum());
1162                 }
1163             }
1164         });
1165     }
1166
1167     /**
1168      * Schedule the reconnection job
1169      */
1170     private void scheduleReconnectJob() {
1171         logger.debug("Schedule reconnect job");
1172         cancelReconnectJob();
1173         reconnectJob = scheduler.scheduleWithFixedDelay(() -> {
1174             if (!connector.isConnected()) {
1175                 logger.debug("Trying to reconnect...");
1176                 closeConnection();
1177                 if (openConnection()) {
1178                     logger.debug("Reconnected");
1179                     // Polling status will disconnect from MPS4 on reconnect
1180                     if (!isMps4) {
1181                         pollStatus();
1182                     }
1183                     enableNuvonet(true);
1184                 } else {
1185                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Reconnection failed");
1186                     closeConnection();
1187                 }
1188             }
1189         }, 1, RECON_POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
1190     }
1191
1192     /**
1193      * If a ping is not received within ping interval the connection is closed and a reconnect job is scheduled
1194      */
1195     private void schedulePingTimeoutJob() {
1196         if (isMps4) {
1197             logger.debug("Schedule Ping Timeout job");
1198             cancelPingTimeoutJob();
1199             pingJob = scheduler.schedule(() -> {
1200                 closeConnection();
1201                 scheduleReconnectJob();
1202             }, PING_TIMEOUT_SEC, TimeUnit.SECONDS);
1203         } else {
1204             logger.debug("Ping Timeout job not valid for serial connections");
1205         }
1206     }
1207
1208     /**
1209      * Cancel the ping timeout job
1210      */
1211     private void cancelPingTimeoutJob() {
1212         ScheduledFuture<?> pingJob = this.pingJob;
1213         if (pingJob != null) {
1214             pingJob.cancel(true);
1215             this.pingJob = null;
1216         }
1217     }
1218
1219     private void pollStatus() {
1220         pollStatusNeeded = false;
1221         scheduler.submit(() -> {
1222             synchronized (sequenceLock) {
1223                 try {
1224                     connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
1225
1226                     NuvoEnum.VALID_SOURCES.forEach(source -> {
1227                         try {
1228                             connector.sendQuery(source, NuvoCommand.NAME);
1229                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1230                             connector.sendQuery(source, NuvoCommand.DISPINFO);
1231                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1232                             connector.sendQuery(source, NuvoCommand.DISPLINE);
1233                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1234                         } catch (NuvoException | InterruptedException e) {
1235                             logger.debug("Error Querying Source data: {}", e.getMessage());
1236                         }
1237                     });
1238
1239                     // Query all active zones to get their current status and eq configuration
1240                     activeZones.forEach(zoneNum -> {
1241                         try {
1242                             connector.sendQuery(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.STATUS);
1243                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1244                             connector.sendCfgCommand(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.STATUS_QUERY, BLANK);
1245                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1246                             connector.sendCfgCommand(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.EQ_QUERY, BLANK);
1247                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1248                         } catch (NuvoException | InterruptedException e) {
1249                             logger.debug("Error Querying Zone data: {}", e.getMessage());
1250                         }
1251                     });
1252
1253                     List<StateOption> sourceStateOptions = new ArrayList<>();
1254                     sourceLabels.keySet().forEach(key -> {
1255                         sourceStateOptions.add(new StateOption(key, sourceLabels.get(key)));
1256                     });
1257
1258                     // Put the source labels on all active zones
1259                     activeZones.forEach(zoneNum -> {
1260                         stateDescriptionProvider.setStateOptions(
1261                                 new ChannelUID(getThing().getUID(),
1262                                         ZONE.toLowerCase() + zoneNum + CHANNEL_DELIMIT + CHANNEL_TYPE_SOURCE),
1263                                 sourceStateOptions);
1264                     });
1265                 } catch (NuvoException e) {
1266                     logger.debug("Error polling status from Nuvo: {}", e.getMessage());
1267                 }
1268             }
1269         });
1270     }
1271
1272     /**
1273      * Cancel the reconnection job
1274      */
1275     private void cancelReconnectJob() {
1276         ScheduledFuture<?> reconnectJob = this.reconnectJob;
1277         if (reconnectJob != null) {
1278             reconnectJob.cancel(true);
1279             this.reconnectJob = null;
1280         }
1281     }
1282
1283     /**
1284      * Schedule the polling job
1285      */
1286     private void schedulePollingJob() {
1287         cancelPollingJob();
1288
1289         if (isMps4) {
1290             logger.debug("MPS4 doesn't support polling");
1291             return;
1292         } else {
1293             logger.debug("Schedule polling job");
1294         }
1295
1296         // when the Nuvo amp is off, this will keep the connection (esp Serial over IP) alive and detect if the
1297         // connection goes down
1298         pollingJob = scheduler.scheduleWithFixedDelay(() -> {
1299             if (connector.isConnected()) {
1300                 logger.debug("Polling the component for updated status...");
1301
1302                 synchronized (sequenceLock) {
1303                     try {
1304                         connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
1305                     } catch (NuvoException e) {
1306                         logger.debug("Polling error: {}", e.getMessage());
1307                     }
1308
1309                     // if the last event received was more than 1.25 intervals ago,
1310                     // the component is not responding even though the connection is still good
1311                     if ((System.currentTimeMillis() - lastEventReceived) > (POLLING_INTERVAL_SEC * 1.25 * 1000)) {
1312                         logger.debug("Component not responding to status requests");
1313                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1314                                 "Component not responding to status requests");
1315                         closeConnection();
1316                         scheduleReconnectJob();
1317                     }
1318                 }
1319             }
1320         }, INITIAL_POLLING_DELAY_SEC, POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
1321     }
1322
1323     /**
1324      * Cancel the polling job
1325      */
1326     private void cancelPollingJob() {
1327         ScheduledFuture<?> pollingJob = this.pollingJob;
1328         if (pollingJob != null) {
1329             pollingJob.cancel(true);
1330             this.pollingJob = null;
1331         }
1332     }
1333
1334     /**
1335      * Schedule the clock sync job
1336      */
1337     private void scheduleClockSyncJob() {
1338         logger.debug("Schedule clock sync job");
1339         cancelClockSyncJob();
1340         clockSyncJob = scheduler.scheduleWithFixedDelay(() -> {
1341             if (this.isGConcerto) {
1342                 try {
1343                     SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy,MM,dd,HH,mm");
1344                     connector.sendCommand(NuvoCommand.CFGTIME.getValue() + dateFormat.format(new Date()));
1345                 } catch (NuvoException e) {
1346                     logger.debug("Error syncing clock: {}", e.getMessage());
1347                 }
1348             } else {
1349                 this.cancelClockSyncJob();
1350             }
1351         }, INITIAL_CLOCK_SYNC_DELAY_SEC, CLOCK_SYNC_INTERVAL_SEC, TimeUnit.SECONDS);
1352     }
1353
1354     /**
1355      * Cancel the clock sync job
1356      */
1357     private void cancelClockSyncJob() {
1358         ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
1359         if (clockSyncJob != null) {
1360             clockSyncJob.cancel(true);
1361             this.clockSyncJob = null;
1362         }
1363     }
1364
1365     /**
1366      * Update the state of a channel (original method signature)
1367      *
1368      * @param target the channel group
1369      * @param channelType the channel group item
1370      * @param value the value to be updated
1371      */
1372     private void updateChannelState(NuvoEnum target, String channelType, String value) {
1373         updateChannelState(target, channelType, value, NO_ART);
1374     }
1375
1376     /**
1377      * Update the state of a channel (overloaded method to handle album_art channel)
1378      *
1379      * @param target the channel group
1380      * @param channelType the channel group item
1381      * @param value the value to be updated
1382      * @param bytes the byte[] to load into the Image channel
1383      */
1384     private void updateChannelState(NuvoEnum target, String channelType, String value, byte[] bytes) {
1385         String channel = target.name().toLowerCase() + CHANNEL_DELIMIT + channelType;
1386
1387         if (!isLinked(channel)) {
1388             return;
1389         }
1390
1391         State state = UnDefType.UNDEF;
1392
1393         if (UNDEF.equals(value)) {
1394             updateState(channel, state);
1395             return;
1396         }
1397
1398         switch (channelType) {
1399             case CHANNEL_TYPE_POWER:
1400             case CHANNEL_TYPE_MUTE:
1401             case CHANNEL_TYPE_DND:
1402             case CHANNEL_TYPE_PARTY:
1403             case CHANNEL_TYPE_ALLMUTE:
1404             case CHANNEL_TYPE_PAGE:
1405             case CHANNEL_TYPE_LOUDNESS:
1406                 state = OnOffType.from(ON.equals(value));
1407                 break;
1408             case CHANNEL_TYPE_LOCK:
1409                 state = ON.equals(value) ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
1410                 break;
1411             case CHANNEL_TYPE_SOURCE:
1412             case CHANNEL_TYPE_TREBLE:
1413             case CHANNEL_TYPE_BASS:
1414             case CHANNEL_TYPE_BALANCE:
1415                 state = new DecimalType(value);
1416                 break;
1417             case CHANNEL_TYPE_VOLUME:
1418                 int volume = Integer.parseInt(value);
1419                 long volumePct = Math
1420                         .round((double) (MAX_VOLUME - volume) / (double) (MAX_VOLUME - MIN_VOLUME) * 100.0);
1421                 state = new PercentType(BigDecimal.valueOf(volumePct));
1422                 break;
1423             case CHANNEL_TYPE_BUTTONPRESS:
1424             case CHANNEL_DISPLAY_LINE1:
1425             case CHANNEL_DISPLAY_LINE2:
1426             case CHANNEL_DISPLAY_LINE3:
1427             case CHANNEL_DISPLAY_LINE4:
1428             case CHANNEL_BUTTON_PRESS:
1429                 state = new StringType(value);
1430                 break;
1431             case CHANNEL_PLAY_MODE:
1432                 state = new StringType(NuvoStatusCodes.PLAY_MODE.get(value));
1433                 break;
1434             case CHANNEL_TRACK_LENGTH:
1435             case CHANNEL_TRACK_POSITION:
1436                 state = new QuantityType<>(Integer.parseInt(value) / 10, NuvoHandler.API_SECOND_UNIT);
1437                 break;
1438             case CHANNEL_ALBUM_ART:
1439                 state = new RawType(bytes, "image/jpeg");
1440                 break;
1441             default:
1442                 break;
1443         }
1444         updateState(channel, state);
1445     }
1446
1447     /**
1448      * For grouped zones, update the source channel for all group members
1449      *
1450      * @param zoneEnum the zone where the source was changed
1451      * @param srcId the new source number that was selected
1452      */
1453     private void updateSrcForZoneGroup(NuvoEnum zoneEnum, String srcId) {
1454         // check if this zone is in a group, if so update the other group member's selected source
1455         nuvoGroupMap.forEach((groupId, groupZones) -> {
1456             if (groupZones.contains(zoneEnum)) {
1457                 groupZones.forEach(z -> {
1458                     if (!zoneEnum.equals(z)) {
1459                         updateChannelState(z, CHANNEL_TYPE_SOURCE, srcId);
1460                     }
1461                 });
1462             }
1463         });
1464     }
1465
1466     /**
1467      * Handle a button press from a UI Player item
1468      *
1469      * @param target the nuvo zone to receive the command
1470      * @param command the button press command to send to the zone
1471      */
1472     private void handleControlCommand(NuvoEnum target, Command command) throws NuvoException {
1473         if (command instanceof PlayPauseType) {
1474             connector.sendCommand(target, NuvoCommand.PLAYPAUSE);
1475         } else if (command instanceof NextPreviousType) {
1476             if (command == NextPreviousType.NEXT) {
1477                 connector.sendCommand(target, NuvoCommand.NEXT);
1478             } else if (command == NextPreviousType.PREVIOUS) {
1479                 connector.sendCommand(target, NuvoCommand.PREV);
1480             }
1481         } else {
1482             logger.warn("Unknown control command: {}", command);
1483         }
1484     }
1485
1486     /**
1487      * Scrapes the MPS4's json api to retrieve the currently playing media's album art
1488      *
1489      * @param source the source that should be queried to load the current album art
1490      */
1491     private void getMps4AlbumArt(NuvoEnum source) {
1492         final String clientId = UUID.randomUUID().toString();
1493
1494         // try to get cached source instance
1495         String instance = sourceInstanceMap.get(source);
1496
1497         // if not found, need to retrieve from the api, once found these calls will be skipped
1498         if (instance == null) {
1499             // find which zone is using this source
1500             NuvoEnum zone = sourceZoneMap.get(source);
1501
1502             if (zone == null) {
1503                 logger.debug("Unable to determine zone that is using source {}", source);
1504                 return;
1505             } else {
1506                 try {
1507                     final String json = getMcsJson(String.format(GET_MCS_INSTANCE, mps4Host, zone.getNum(), clientId),
1508                             clientId);
1509
1510                     Matcher matcher = MCS_INSTANCE_PATTERN.matcher(json);
1511                     if (matcher.find()) {
1512                         instance = matcher.group(1);
1513                         sourceInstanceMap.put(source, instance);
1514                         logger.debug("Found instance '{}' for source {}", instance, source);
1515                     } else {
1516                         logger.debug("No instance match found for json: {}", json);
1517                         return;
1518                     }
1519                 } catch (TimeoutException | ExecutionException e) {
1520                     logger.debug("Failed getting instance name", e);
1521                     return;
1522                 } catch (InterruptedException e) {
1523                     logger.debug("InterruptedException getting instance name", e);
1524                     Thread.currentThread().interrupt();
1525                     return;
1526                 }
1527             }
1528         }
1529
1530         try {
1531             logger.debug("Using MCS instance '{}' for source {}", instance, source);
1532             final String json = getMcsJson(String.format(GET_MCS_STATUS, mps4Host, instance, clientId), clientId);
1533
1534             if (json.contains("\"name\":\"PlayState\",\"value\":3}")) {
1535                 Matcher matcher = ART_GUID_PATTERN.matcher(json);
1536                 if (matcher.find()) {
1537                     final String nowPlayingGuid = matcher.group(1);
1538
1539                     // If streaming (NowPlayingType=Radio), always retrieve because the same NowPlayingGuid can
1540                     // get a different image written to it by Gracenote when the track changes
1541                     if (!mps4ArtGuids.get(source).equals(nowPlayingGuid)
1542                             || json.contains("NowPlayingType\",\"value\":\"Radio\"}")) {
1543                         ContentResponse artResponse = httpClient
1544                                 .newRequest(String.format(GET_MCS_ART, mps4Host, nowPlayingGuid, instance)).method(GET)
1545                                 .timeout(10, TimeUnit.SECONDS).send();
1546
1547                         if (artResponse.getStatus() == OK_200) {
1548                             logger.debug("Retrieved album art for guid: {}", nowPlayingGuid);
1549                             updateChannelState(source, CHANNEL_ALBUM_ART, BLANK, artResponse.getContent());
1550                             mps4ArtGuids.put(source, nowPlayingGuid);
1551                         }
1552                     } else {
1553                         logger.debug("Album art has not changed, guid: {}", nowPlayingGuid);
1554                     }
1555                 } else {
1556                     logger.debug("NowPlayingGuid not found");
1557                 }
1558             } else {
1559                 logger.debug("PlayState not valid");
1560             }
1561         } catch (TimeoutException | ExecutionException e) {
1562             logger.debug("Failed getting album art", e);
1563             updateChannelState(source, CHANNEL_ALBUM_ART, UNDEF);
1564             mps4ArtGuids.put(source, BLANK);
1565         } catch (InterruptedException e) {
1566             logger.debug("InterruptedException getting album art", e);
1567             updateChannelState(source, CHANNEL_ALBUM_ART, UNDEF);
1568             mps4ArtGuids.put(source, BLANK);
1569             Thread.currentThread().interrupt();
1570         }
1571     }
1572
1573     /**
1574      * Used by getMps4AlbumArt to abstract retrieval of status json from MCS
1575      *
1576      * @param commandUrl the url with the embedded commands to send to MCS
1577      * @param clientId the current clientId
1578      * @return string json result from the command executed
1579      *
1580      * @throws InterruptedException
1581      * @throws TimeoutException
1582      * @throws ExecutionException
1583      */
1584     private String getMcsJson(String commandUrl, String clientId)
1585             throws InterruptedException, TimeoutException, ExecutionException {
1586         ContentResponse commandResp = httpClient.newRequest(commandUrl).method(GET).timeout(10, TimeUnit.SECONDS)
1587                 .send();
1588
1589         if (commandResp.getStatus() == OK_200) {
1590             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1591             ContentResponse jsonResp = httpClient.newRequest(String.format(GET_MCS_JSON, mps4Host, clientId))
1592                     .method(GET).timeout(10, TimeUnit.SECONDS).send();
1593             if (jsonResp.getStatus() == OK_200) {
1594                 return jsonResp.getContentAsString();
1595             } else {
1596                 logger.debug("Got error response {} when getting json from MCS", commandResp.getStatus());
1597                 return BLANK;
1598             }
1599         }
1600         logger.debug("Got error response {} when sending json command url: {}", commandResp.getStatus(), commandUrl);
1601         return BLANK;
1602     }
1603 }