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