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