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