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