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