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