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