]> git.basschouten.com Git - openhab-addons.git/blob
c1e73396845309c3758675945b50c04213b66abf
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.nuvo.internal.handler;
14
15 import static org.openhab.binding.nuvo.internal.NuvoBindingConstants.*;
16
17 import java.math.BigDecimal;
18 import java.text.SimpleDateFormat;
19 import java.util.ArrayList;
20 import java.util.Collection;
21 import java.util.Collections;
22 import java.util.Date;
23 import java.util.HashSet;
24 import java.util.List;
25 import java.util.Set;
26 import java.util.TreeMap;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
29 import java.util.regex.Matcher;
30 import java.util.regex.Pattern;
31 import java.util.stream.Collectors;
32 import java.util.stream.IntStream;
33
34 import javax.measure.Unit;
35 import javax.measure.quantity.Time;
36
37 import org.eclipse.jdt.annotation.NonNullByDefault;
38 import org.eclipse.jdt.annotation.Nullable;
39 import org.openhab.binding.nuvo.internal.NuvoException;
40 import org.openhab.binding.nuvo.internal.NuvoStateDescriptionOptionProvider;
41 import org.openhab.binding.nuvo.internal.NuvoThingActions;
42 import org.openhab.binding.nuvo.internal.communication.NuvoCommand;
43 import org.openhab.binding.nuvo.internal.communication.NuvoConnector;
44 import org.openhab.binding.nuvo.internal.communication.NuvoDefaultConnector;
45 import org.openhab.binding.nuvo.internal.communication.NuvoEnum;
46 import org.openhab.binding.nuvo.internal.communication.NuvoIpConnector;
47 import org.openhab.binding.nuvo.internal.communication.NuvoMessageEvent;
48 import org.openhab.binding.nuvo.internal.communication.NuvoMessageEventListener;
49 import org.openhab.binding.nuvo.internal.communication.NuvoSerialConnector;
50 import org.openhab.binding.nuvo.internal.communication.NuvoStatusCodes;
51 import org.openhab.binding.nuvo.internal.configuration.NuvoThingConfiguration;
52 import org.openhab.core.io.transport.serial.SerialPortManager;
53 import org.openhab.core.library.types.DecimalType;
54 import org.openhab.core.library.types.NextPreviousType;
55 import org.openhab.core.library.types.OnOffType;
56 import org.openhab.core.library.types.OpenClosedType;
57 import org.openhab.core.library.types.PercentType;
58 import org.openhab.core.library.types.PlayPauseType;
59 import org.openhab.core.library.types.QuantityType;
60 import org.openhab.core.library.types.StringType;
61 import org.openhab.core.library.unit.Units;
62 import org.openhab.core.thing.Channel;
63 import org.openhab.core.thing.ChannelUID;
64 import org.openhab.core.thing.Thing;
65 import org.openhab.core.thing.ThingStatus;
66 import org.openhab.core.thing.ThingStatusDetail;
67 import org.openhab.core.thing.binding.BaseThingHandler;
68 import org.openhab.core.thing.binding.ThingHandlerService;
69 import org.openhab.core.types.Command;
70 import org.openhab.core.types.State;
71 import org.openhab.core.types.StateOption;
72 import org.openhab.core.types.UnDefType;
73 import org.slf4j.Logger;
74 import org.slf4j.LoggerFactory;
75
76 /**
77  * The {@link NuvoHandler} is responsible for handling commands, which are sent to one of the channels.
78  *
79  * Based on the Rotel binding by Laurent Garnier
80  *
81  * @author Michael Lobstein - Initial contribution
82  */
83 @NonNullByDefault
84 public class NuvoHandler extends BaseThingHandler implements NuvoMessageEventListener {
85     private static final long RECON_POLLING_INTERVAL_SEC = 60;
86     private static final long POLLING_INTERVAL_SEC = 30;
87     private static final long CLOCK_SYNC_INTERVAL_SEC = 3600;
88     private static final long INITIAL_POLLING_DELAY_SEC = 30;
89     private static final long INITIAL_CLOCK_SYNC_DELAY_SEC = 10;
90     private static final long PING_TIMEOUT_SEC = 60;
91     // spec says wait 50ms, min is 100
92     private static final long SLEEP_BETWEEN_CMD_MS = 100;
93     private static final Unit<Time> API_SECOND_UNIT = Units.SECOND;
94
95     private static final String ZONE = "ZONE";
96     private static final String SOURCE = "SOURCE";
97     private static final String CHANNEL_DELIMIT = "#";
98     private static final String UNDEF = "UNDEF";
99     private static final String GC_STR = "NV-I8G";
100
101     private static final int MAX_ZONES = 20;
102     private static final int MAX_SRC = 6;
103     private static final int MAX_FAV = 12;
104     private static final int MIN_VOLUME = 0;
105     private static final int MAX_VOLUME = 79;
106     private static final int MIN_EQ = -18;
107     private static final int MAX_EQ = 18;
108
109     private static final int MPS4_PORT = 5006;
110
111     private static final Pattern ZONE_PATTERN = Pattern
112             .compile("^ON,SRC(\\d{1}),(MUTE|VOL\\d{1,2}),DND([0-1]),LOCK([0-1])$");
113     private static final Pattern DISP_PATTERN = Pattern.compile("^DISPLINE(\\d{1}),\"(.*)\"$");
114     private static final Pattern DISP_INFO_PATTERN = Pattern
115             .compile("^DISPINFO,DUR(\\d{1,6}),POS(\\d{1,6}),STATUS(\\d{1,2})$");
116     private static final Pattern ZONE_CFG_PATTERN = Pattern.compile("^BASS(.*),TREB(.*),BAL(.*),LOUDCMP([0-1])$");
117
118     private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy,MM,dd,HH,mm");
119
120     private final Logger logger = LoggerFactory.getLogger(NuvoHandler.class);
121     private final NuvoStateDescriptionOptionProvider stateDescriptionProvider;
122     private final SerialPortManager serialPortManager;
123
124     private @Nullable ScheduledFuture<?> reconnectJob;
125     private @Nullable ScheduledFuture<?> pollingJob;
126     private @Nullable ScheduledFuture<?> clockSyncJob;
127     private @Nullable ScheduledFuture<?> pingJob;
128
129     private NuvoConnector connector = new NuvoDefaultConnector();
130     private long lastEventReceived = System.currentTimeMillis();
131     private int numZones = 1;
132     private String versionString = BLANK;
133     private boolean isGConcerto = false;
134     private Object sequenceLock = new Object();
135
136     Set<Integer> activeZones = new HashSet<>(1);
137
138     // A tree map that maps the source ids to source labels
139     TreeMap<String, String> sourceLabels = new TreeMap<String, String>();
140
141     // Indicates if there is a need to poll status because of a disconnection used for MPS4 systems
142     boolean pollStatusNeeded = true;
143     boolean isMps4 = false;
144
145     /**
146      * Constructor
147      */
148     public NuvoHandler(Thing thing, NuvoStateDescriptionOptionProvider stateDescriptionProvider,
149             SerialPortManager serialPortManager) {
150         super(thing);
151         this.stateDescriptionProvider = stateDescriptionProvider;
152         this.serialPortManager = serialPortManager;
153     }
154
155     @Override
156     public void initialize() {
157         final String uid = this.getThing().getUID().getAsString();
158         NuvoThingConfiguration config = getConfigAs(NuvoThingConfiguration.class);
159         final String serialPort = config.serialPort;
160         final String host = config.host;
161         final Integer port = config.port;
162         final Integer numZones = config.numZones;
163
164         // Check configuration settings
165         String configError = null;
166         if ((serialPort == null || serialPort.isEmpty()) && (host == null || host.isEmpty())) {
167             configError = "undefined serialPort and host configuration settings; please set one of them";
168         } else if (serialPort != null && (host == null || host.isEmpty())) {
169             if (serialPort.toLowerCase().startsWith("rfc2217")) {
170                 configError = "use host and port configuration settings for a serial over IP connection";
171             }
172         } else {
173             if (port == null) {
174                 configError = "undefined port configuration setting";
175             } else if (port <= 0) {
176                 configError = "invalid port configuration setting";
177             }
178         }
179
180         if (configError != null) {
181             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configError);
182             return;
183         }
184
185         if (serialPort != null) {
186             connector = new NuvoSerialConnector(serialPortManager, serialPort, uid);
187         } else if (port != null) {
188             connector = new NuvoIpConnector(host, port, uid);
189         } else {
190             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
191                     "Either Serial port or Host & Port must be specifed");
192             return;
193         }
194
195         this.isMps4 = (port != null && port.intValue() == MPS4_PORT);
196         if (this.isMps4) {
197             logger.debug("Port set to {} configuring binding for MPS4 compatability", MPS4_PORT);
198         }
199
200         if (numZones != null) {
201             this.numZones = numZones;
202         }
203
204         activeZones = IntStream.range((1), (this.numZones + 1)).boxed().collect(Collectors.toSet());
205
206         // remove the channels for the zones we are not using
207         if (this.numZones < MAX_ZONES) {
208             List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
209
210             List<Integer> zonesToRemove = IntStream.range((this.numZones + 1), (MAX_ZONES + 1)).boxed()
211                     .collect(Collectors.toList());
212
213             zonesToRemove.forEach(zone -> channels.removeIf(c -> (c.getUID().getId().contains("zone" + zone))));
214             updateThing(editThing().withChannels(channels).build());
215         }
216
217         if (config.clockSync) {
218             scheduleClockSyncJob();
219         }
220
221         scheduleReconnectJob();
222         schedulePollingJob();
223         schedulePingTimeoutJob();
224         updateStatus(ThingStatus.UNKNOWN);
225     }
226
227     @Override
228     public void dispose() {
229         cancelReconnectJob();
230         cancelPollingJob();
231         cancelClockSyncJob();
232         cancelPingTimeoutJob();
233         closeConnection();
234         super.dispose();
235     }
236
237     @Override
238     public Collection<Class<? extends ThingHandlerService>> getServices() {
239         return Collections.singletonList(NuvoThingActions.class);
240     }
241
242     public void handleRawCommand(@Nullable String command) {
243         synchronized (sequenceLock) {
244             try {
245                 connector.sendCommand(command);
246             } catch (NuvoException e) {
247                 logger.warn("Nuvo Command: {} failed", command);
248             }
249         }
250     }
251
252     /**
253      * Handle a command the UI
254      *
255      * @param channelUID the channel sending the command
256      * @param command the command received
257      *
258      */
259     @Override
260     public void handleCommand(ChannelUID channelUID, Command command) {
261         String channel = channelUID.getId();
262         String[] channelSplit = channel.split(CHANNEL_DELIMIT);
263         NuvoEnum target = NuvoEnum.valueOf(channelSplit[0].toUpperCase());
264
265         String channelType = channelSplit[1];
266
267         if (getThing().getStatus() != ThingStatus.ONLINE) {
268             logger.debug("Thing is not ONLINE; command {} from channel {} is ignored", command, channel);
269             return;
270         }
271
272         synchronized (sequenceLock) {
273             if (!connector.isConnected()) {
274                 logger.warn("Command {} from channel {} is ignored: connection not established", command, channel);
275                 return;
276             }
277
278             try {
279                 switch (channelType) {
280                     case CHANNEL_TYPE_POWER:
281                         if (command instanceof OnOffType) {
282                             connector.sendCommand(target, command == OnOffType.ON ? NuvoCommand.ON : NuvoCommand.OFF);
283                         }
284                         break;
285                     case CHANNEL_TYPE_SOURCE:
286                         if (command instanceof DecimalType) {
287                             int value = ((DecimalType) command).intValue();
288                             if (value >= 1 && value <= MAX_SRC) {
289                                 logger.debug("Got source command {} zone {}", value, target);
290                                 connector.sendCommand(target, NuvoCommand.SOURCE, String.valueOf(value));
291                             }
292                         }
293                         break;
294                     case CHANNEL_TYPE_FAVORITE:
295                         if (command instanceof DecimalType) {
296                             int value = ((DecimalType) command).intValue();
297                             if (value >= 1 && value <= MAX_FAV) {
298                                 logger.debug("Got favorite command {} zone {}", value, target);
299                                 connector.sendCommand(target, NuvoCommand.FAVORITE, String.valueOf(value));
300                             }
301                         }
302                         break;
303                     case CHANNEL_TYPE_VOLUME:
304                         if (command instanceof PercentType) {
305                             int value = (MAX_VOLUME
306                                     - (int) Math.round(
307                                             ((PercentType) command).doubleValue() / 100.0 * (MAX_VOLUME - MIN_VOLUME))
308                                     + MIN_VOLUME);
309                             logger.debug("Got volume command {} zone {}", value, target);
310                             connector.sendCommand(target, NuvoCommand.VOLUME, String.valueOf(value));
311                         }
312                         break;
313                     case CHANNEL_TYPE_MUTE:
314                         if (command instanceof OnOffType) {
315                             connector.sendCommand(target,
316                                     command == OnOffType.ON ? NuvoCommand.MUTE_ON : NuvoCommand.MUTE_OFF);
317                         }
318                         break;
319                     case CHANNEL_TYPE_TREBLE:
320                         if (command instanceof DecimalType) {
321                             int value = ((DecimalType) command).intValue();
322                             if (value >= MIN_EQ && value <= MAX_EQ) {
323                                 // device can only accept even values
324                                 if (value % 2 == 1) {
325                                     value++;
326                                 }
327                                 logger.debug("Got treble command {} zone {}", value, target);
328                                 connector.sendCfgCommand(target, NuvoCommand.TREBLE, String.valueOf(value));
329                             }
330                         }
331                         break;
332                     case CHANNEL_TYPE_BASS:
333                         if (command instanceof DecimalType) {
334                             int value = ((DecimalType) command).intValue();
335                             if (value >= MIN_EQ && value <= MAX_EQ) {
336                                 if (value % 2 == 1) {
337                                     value++;
338                                 }
339                                 logger.debug("Got bass command {} zone {}", value, target);
340                                 connector.sendCfgCommand(target, NuvoCommand.BASS, String.valueOf(value));
341                             }
342                         }
343                         break;
344                     case CHANNEL_TYPE_BALANCE:
345                         if (command instanceof DecimalType) {
346                             int value = ((DecimalType) command).intValue();
347                             if (value >= MIN_EQ && value <= MAX_EQ) {
348                                 if (value % 2 == 1) {
349                                     value++;
350                                 }
351                                 logger.debug("Got balance command {} zone {}", value, target);
352                                 connector.sendCfgCommand(target, NuvoCommand.BALANCE,
353                                         NuvoStatusCodes.getBalanceFromInt(value));
354                             }
355                         }
356                         break;
357                     case CHANNEL_TYPE_LOUDNESS:
358                         if (command instanceof OnOffType) {
359                             connector.sendCfgCommand(target, NuvoCommand.LOUDNESS,
360                                     command == OnOffType.ON ? ONE : ZERO);
361                         }
362                         break;
363                     case CHANNEL_TYPE_CONTROL:
364                         handleControlCommand(target, command);
365                         break;
366                     case CHANNEL_TYPE_DND:
367                         if (command instanceof OnOffType) {
368                             connector.sendCommand(target,
369                                     command == OnOffType.ON ? NuvoCommand.DND_ON : NuvoCommand.DND_OFF);
370                         }
371                         break;
372                     case CHANNEL_TYPE_PARTY:
373                         if (command instanceof OnOffType) {
374                             connector.sendCommand(target,
375                                     command == OnOffType.ON ? NuvoCommand.PARTY_ON : NuvoCommand.PARTY_OFF);
376                         }
377                         break;
378                     case CHANNEL_DISPLAY_LINE1:
379                         if (command instanceof StringType) {
380                             connector.sendCommand(target, NuvoCommand.DISPLINE1, "\"" + command + "\"");
381                         }
382                         break;
383                     case CHANNEL_DISPLAY_LINE2:
384                         if (command instanceof StringType) {
385                             connector.sendCommand(target, NuvoCommand.DISPLINE2, "\"" + command + "\"");
386                         }
387                         break;
388                     case CHANNEL_DISPLAY_LINE3:
389                         if (command instanceof StringType) {
390                             connector.sendCommand(target, NuvoCommand.DISPLINE3, "\"" + command + "\"");
391                         }
392                         break;
393                     case CHANNEL_DISPLAY_LINE4:
394                         if (command instanceof StringType) {
395                             connector.sendCommand(target, NuvoCommand.DISPLINE4, "\"" + command + "\"");
396                         }
397                         break;
398                     case CHANNEL_TYPE_ALLOFF:
399                         if (command instanceof OnOffType) {
400                             connector.sendCommand(NuvoCommand.ALLOFF);
401                         }
402                         break;
403                     case CHANNEL_TYPE_ALLMUTE:
404                         if (command instanceof OnOffType) {
405                             connector.sendCommand(
406                                     command == OnOffType.ON ? NuvoCommand.ALLMUTE_ON : NuvoCommand.ALLMUTE_OFF);
407                         }
408                         break;
409                     case CHANNEL_TYPE_PAGE:
410                         if (command instanceof OnOffType) {
411                             connector.sendCommand(command == OnOffType.ON ? NuvoCommand.PAGE_ON : NuvoCommand.PAGE_OFF);
412                         }
413                         break;
414                 }
415             } catch (NuvoException e) {
416                 logger.warn("Command {} from channel {} failed: {}", command, channel, e.getMessage());
417                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Sending command failed");
418                 closeConnection();
419                 scheduleReconnectJob();
420             }
421         }
422     }
423
424     /**
425      * Open the connection with the Nuvo device
426      *
427      * @return true if the connection is opened successfully or false if not
428      */
429     private synchronized boolean openConnection() {
430         connector.addEventListener(this);
431         try {
432             connector.open();
433         } catch (NuvoException e) {
434             logger.debug("openConnection() failed: {}", e.getMessage());
435         }
436         logger.debug("openConnection(): {}", connector.isConnected() ? "connected" : "disconnected");
437         return connector.isConnected();
438     }
439
440     /**
441      * Close the connection with the Nuvo device
442      */
443     private synchronized void closeConnection() {
444         if (connector.isConnected()) {
445             connector.close();
446             connector.removeEventListener(this);
447             pollStatusNeeded = true;
448             logger.debug("closeConnection(): disconnected");
449         }
450     }
451
452     /**
453      * Handle an event received from the Nuvo device
454      *
455      * @param event the event to process
456      */
457     @Override
458     public void onNewMessageEvent(NuvoMessageEvent evt) {
459         logger.debug("onNewMessageEvent: key {} = {}", evt.getKey(), evt.getValue());
460         lastEventReceived = System.currentTimeMillis();
461
462         String type = evt.getType();
463         String key = evt.getKey();
464         String updateData = evt.getValue().trim();
465         if (this.getThing().getStatus() == ThingStatus.OFFLINE) {
466             updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, this.versionString);
467         }
468
469         switch (type) {
470             case TYPE_VERSION:
471                 this.versionString = updateData;
472                 // Determine if we are a Grand Concerto or not
473                 if (this.versionString.contains(GC_STR)) {
474                     logger.debug("Grand Concerto detected");
475                     this.isGConcerto = true;
476                     connector.setEssentia(false);
477                 } else {
478                     logger.debug("Grand Concerto not detected");
479                 }
480                 break;
481             case TYPE_PING:
482                 logger.debug("Ping message received- rescheduling ping timeout");
483                 schedulePingTimeoutJob();
484                 // Return here because receiving a ping does not indicate that one can poll
485                 return;
486             case TYPE_ALLOFF:
487                 activeZones.forEach(zoneNum -> {
488                     updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_POWER, OFF);
489                 });
490                 break;
491             case TYPE_ALLMUTE:
492                 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_ALLMUTE, ONE.equals(updateData) ? ON : OFF);
493                 activeZones.forEach(zoneNum -> {
494                     updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_MUTE,
495                             ONE.equals(updateData) ? ON : OFF);
496                 });
497                 break;
498             case TYPE_PAGE:
499                 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_PAGE, ONE.equals(updateData) ? ON : OFF);
500                 break;
501             case TYPE_SOURCE_UPDATE:
502                 logger.debug("Source update: Source: {} - Value: {}", key, updateData);
503                 NuvoEnum targetSource = NuvoEnum.valueOf(SOURCE + key);
504
505                 if (updateData.contains(DISPLINE)) {
506                     // example: DISPLINE2,"Play My Song (Featuring Dee Ajayi)"
507                     Matcher matcher = DISP_PATTERN.matcher(updateData);
508                     if (matcher.find()) {
509                         updateChannelState(targetSource, CHANNEL_DISPLAY_LINE + matcher.group(1), matcher.group(2));
510                     } else {
511                         logger.debug("no match on message: {}", updateData);
512                     }
513                 } else if (updateData.contains(DISPINFO)) {
514                     // example: DISPINFO,DUR0,POS70,STATUS2 (DUR and POS are expressed in tenths of a second)
515                     // 6 places(tenths of a second)-> max 999,999 /10/60/60/24 = 1.15 days
516                     Matcher matcher = DISP_INFO_PATTERN.matcher(updateData);
517                     if (matcher.find()) {
518                         updateChannelState(targetSource, CHANNEL_TRACK_LENGTH, matcher.group(1));
519                         updateChannelState(targetSource, CHANNEL_TRACK_POSITION, matcher.group(2));
520                         updateChannelState(targetSource, CHANNEL_PLAY_MODE, matcher.group(3));
521                     } else {
522                         logger.debug("no match on message: {}", updateData);
523                     }
524                 } else if (updateData.contains(NAME_QUOTE)) {
525                     // example: NAME"Ipod"
526                     String name = updateData.split("\"")[1];
527                     sourceLabels.put(key, name);
528                 }
529                 break;
530             case TYPE_ZONE_UPDATE:
531                 logger.debug("Zone update: Zone: {} - Value: {}", key, updateData);
532                 // example : OFF
533                 // or: ON,SRC3,VOL63,DND0,LOCK0
534                 // or: ON,SRC3,MUTE,DND0,LOCK0
535
536                 NuvoEnum targetZone = NuvoEnum.valueOf(ZONE + key);
537
538                 if (OFF.equals(updateData)) {
539                     updateChannelState(targetZone, CHANNEL_TYPE_POWER, OFF);
540                     updateChannelState(targetZone, CHANNEL_TYPE_SOURCE, UNDEF);
541                 } else {
542                     Matcher matcher = ZONE_PATTERN.matcher(updateData);
543                     if (matcher.find()) {
544                         updateChannelState(targetZone, CHANNEL_TYPE_POWER, ON);
545                         updateChannelState(targetZone, CHANNEL_TYPE_SOURCE, matcher.group(1));
546
547                         if (MUTE.equals(matcher.group(2))) {
548                             updateChannelState(targetZone, CHANNEL_TYPE_MUTE, ON);
549                         } else {
550                             updateChannelState(targetZone, CHANNEL_TYPE_MUTE, NuvoCommand.OFF.getValue());
551                             updateChannelState(targetZone, CHANNEL_TYPE_VOLUME, matcher.group(2).replace(VOL, BLANK));
552                         }
553
554                         updateChannelState(targetZone, CHANNEL_TYPE_DND, ONE.equals(matcher.group(3)) ? ON : OFF);
555                         updateChannelState(targetZone, CHANNEL_TYPE_LOCK, ONE.equals(matcher.group(4)) ? ON : OFF);
556                     } else {
557                         logger.debug("no match on message: {}", updateData);
558                     }
559                 }
560                 break;
561             case TYPE_ZONE_BUTTON:
562                 logger.debug("Zone Button pressed: Source: {} - Button: {}", key, updateData);
563                 updateChannelState(NuvoEnum.valueOf(SOURCE + key), CHANNEL_BUTTON_PRESS, updateData);
564                 break;
565             case TYPE_ZONE_CONFIG:
566                 logger.debug("Zone Configuration: Zone: {} - Value: {}", key, updateData);
567                 // example: BASS1,TREB-2,BALR2,LOUDCMP1
568                 Matcher matcher = ZONE_CFG_PATTERN.matcher(updateData);
569                 if (matcher.find()) {
570                     updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_BASS, matcher.group(1));
571                     updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_TREBLE, matcher.group(2));
572                     updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_BALANCE,
573                             NuvoStatusCodes.getBalanceFromStr(matcher.group(3)));
574                     updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_LOUDNESS,
575                             ONE.equals(matcher.group(4)) ? ON : OFF);
576                 } else {
577                     logger.debug("no match on message: {}", updateData);
578                 }
579                 break;
580             default:
581                 logger.debug("onNewMessageEvent: unhandled key {}", key);
582                 // Return here because receiving an unknown message does not indicate that one can poll
583                 return;
584         }
585
586         if (isMps4 && pollStatusNeeded) {
587             pollStatus();
588         }
589     }
590
591     /**
592      * Schedule the reconnection job
593      */
594     private void scheduleReconnectJob() {
595         logger.debug("Schedule reconnect job");
596         cancelReconnectJob();
597         reconnectJob = scheduler.scheduleWithFixedDelay(() -> {
598             if (!connector.isConnected()) {
599                 logger.debug("Trying to reconnect...");
600                 closeConnection();
601                 String error = null;
602                 if (openConnection()) {
603                     logger.debug("Reconnected");
604                     // Polling status will disconnect from MPS4 on reconnect
605                     if (!isMps4) {
606                         pollStatus();
607                     }
608                 } else {
609                     error = "Reconnection failed";
610                 }
611                 if (error != null) {
612                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, error);
613                     closeConnection();
614                 } else {
615                     updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, this.versionString);
616                 }
617             }
618         }, 1, RECON_POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
619     }
620
621     /**
622      * If a ping is not received within ping interval the connection is closed and a reconnect job is scheduled
623      */
624     private void schedulePingTimeoutJob() {
625         if (isMps4) {
626             logger.debug("Schedule Ping Timeout job");
627             cancelPingTimeoutJob();
628             pingJob = scheduler.schedule(() -> {
629                 closeConnection();
630                 scheduleReconnectJob();
631             }, PING_TIMEOUT_SEC, TimeUnit.SECONDS);
632         } else {
633             logger.debug("Ping Timeout job on valid for MPS4 connections");
634         }
635     }
636
637     /**
638      * Cancel the ping timeout job
639      */
640     private void cancelPingTimeoutJob() {
641         ScheduledFuture<?> pingJob = this.pingJob;
642         if (pingJob != null) {
643             pingJob.cancel(true);
644             this.pingJob = null;
645         }
646     }
647
648     private void pollStatus() {
649         pollStatusNeeded = false;
650         scheduler.submit(() -> {
651             synchronized (sequenceLock) {
652                 try {
653                     connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
654
655                     NuvoEnum.VALID_SOURCES.forEach(source -> {
656                         try {
657                             connector.sendQuery(NuvoEnum.valueOf(source), NuvoCommand.NAME);
658                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
659                             connector.sendQuery(NuvoEnum.valueOf(source), NuvoCommand.DISPINFO);
660                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
661                             connector.sendQuery(NuvoEnum.valueOf(source), NuvoCommand.DISPLINE);
662                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
663                         } catch (NuvoException | InterruptedException e) {
664                             logger.debug("Error Querying Source data: {}", e.getMessage());
665                         }
666                     });
667
668                     // Query all active zones to get their current status and eq configuration
669                     activeZones.forEach(zoneNum -> {
670                         try {
671                             connector.sendQuery(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.STATUS);
672                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
673                             connector.sendCfgCommand(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.EQ_QUERY, BLANK);
674                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
675                         } catch (NuvoException | InterruptedException e) {
676                             logger.debug("Error Querying Zone data: {}", e.getMessage());
677                         }
678                     });
679
680                     List<StateOption> sourceStateOptions = new ArrayList<>();
681                     sourceLabels.keySet().forEach(key -> {
682                         sourceStateOptions.add(new StateOption(key, sourceLabels.get(key)));
683                     });
684
685                     // Put the source labels on all active zones
686                     activeZones.forEach(zoneNum -> {
687                         stateDescriptionProvider.setStateOptions(
688                                 new ChannelUID(getThing().getUID(),
689                                         ZONE.toLowerCase() + zoneNum + CHANNEL_DELIMIT + CHANNEL_TYPE_SOURCE),
690                                 sourceStateOptions);
691                     });
692                 } catch (NuvoException e) {
693                     logger.debug("Error polling status from Nuvo: {}", e.getMessage());
694                 }
695             }
696         });
697     }
698
699     /**
700      * Cancel the reconnection job
701      */
702     private void cancelReconnectJob() {
703         ScheduledFuture<?> reconnectJob = this.reconnectJob;
704         if (reconnectJob != null) {
705             reconnectJob.cancel(true);
706             this.reconnectJob = null;
707         }
708     }
709
710     /**
711      * Schedule the polling job
712      */
713     private void schedulePollingJob() {
714         cancelPollingJob();
715
716         if (isMps4) {
717             logger.debug("MPS4 doesn't support polling");
718             return;
719         } else {
720             logger.debug("Schedule polling job");
721         }
722
723         // when the Nuvo amp is off, this will keep the connection (esp Serial over IP) alive and detect if the
724         // connection goes down
725         pollingJob = scheduler.scheduleWithFixedDelay(() -> {
726             if (connector.isConnected()) {
727                 logger.debug("Polling the component for updated status...");
728
729                 synchronized (sequenceLock) {
730                     try {
731                         connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
732                     } catch (NuvoException e) {
733                         logger.debug("Polling error: {}", e.getMessage());
734                     }
735
736                     // if the last event received was more than 1.25 intervals ago,
737                     // the component is not responding even though the connection is still good
738                     if ((System.currentTimeMillis() - lastEventReceived) > (POLLING_INTERVAL_SEC * 1.25 * 1000)) {
739                         logger.debug("Component not responding to status requests");
740                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
741                                 "Component not responding to status requests");
742                         closeConnection();
743                         scheduleReconnectJob();
744                     }
745                 }
746             }
747         }, INITIAL_POLLING_DELAY_SEC, POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
748     }
749
750     /**
751      * Cancel the polling job
752      */
753     private void cancelPollingJob() {
754         ScheduledFuture<?> pollingJob = this.pollingJob;
755         if (pollingJob != null) {
756             pollingJob.cancel(true);
757             this.pollingJob = null;
758         }
759     }
760
761     /**
762      * Schedule the clock sync job
763      */
764     private void scheduleClockSyncJob() {
765         logger.debug("Schedule clock sync job");
766         cancelClockSyncJob();
767         clockSyncJob = scheduler.scheduleWithFixedDelay(() -> {
768             if (this.isGConcerto) {
769                 try {
770                     connector.sendCommand(NuvoCommand.CFGTIME.getValue() + DATE_FORMAT.format(new Date()));
771                 } catch (NuvoException e) {
772                     logger.debug("Error syncing clock: {}", e.getMessage());
773                 }
774             } else {
775                 this.cancelClockSyncJob();
776             }
777         }, INITIAL_CLOCK_SYNC_DELAY_SEC, CLOCK_SYNC_INTERVAL_SEC, TimeUnit.SECONDS);
778     }
779
780     /**
781      * Cancel the clock sync job
782      */
783     private void cancelClockSyncJob() {
784         ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
785         if (clockSyncJob != null) {
786             clockSyncJob.cancel(true);
787             this.clockSyncJob = null;
788         }
789     }
790
791     /**
792      * Update the state of a channel
793      *
794      * @param target the channel group
795      * @param channelType the channel group item
796      * @param value the value to be updated
797      */
798     private void updateChannelState(NuvoEnum target, String channelType, String value) {
799         String channel = target.name().toLowerCase() + CHANNEL_DELIMIT + channelType;
800
801         if (!isLinked(channel)) {
802             return;
803         }
804
805         State state = UnDefType.UNDEF;
806
807         if (UNDEF.equals(value)) {
808             updateState(channel, state);
809             return;
810         }
811
812         switch (channelType) {
813             case CHANNEL_TYPE_POWER:
814             case CHANNEL_TYPE_MUTE:
815             case CHANNEL_TYPE_DND:
816             case CHANNEL_TYPE_PARTY:
817             case CHANNEL_TYPE_ALLMUTE:
818             case CHANNEL_TYPE_PAGE:
819             case CHANNEL_TYPE_LOUDNESS:
820                 state = ON.equals(value) ? OnOffType.ON : OnOffType.OFF;
821                 break;
822             case CHANNEL_TYPE_LOCK:
823                 state = ON.equals(value) ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
824                 break;
825             case CHANNEL_TYPE_SOURCE:
826             case CHANNEL_TYPE_TREBLE:
827             case CHANNEL_TYPE_BASS:
828             case CHANNEL_TYPE_BALANCE:
829                 state = new DecimalType(value);
830                 break;
831             case CHANNEL_TYPE_VOLUME:
832                 int volume = Integer.parseInt(value);
833                 long volumePct = Math
834                         .round((double) (MAX_VOLUME - volume) / (double) (MAX_VOLUME - MIN_VOLUME) * 100.0);
835                 state = new PercentType(BigDecimal.valueOf(volumePct));
836                 break;
837             case CHANNEL_DISPLAY_LINE1:
838             case CHANNEL_DISPLAY_LINE2:
839             case CHANNEL_DISPLAY_LINE3:
840             case CHANNEL_DISPLAY_LINE4:
841             case CHANNEL_BUTTON_PRESS:
842                 state = new StringType(value);
843                 break;
844             case CHANNEL_PLAY_MODE:
845                 state = new StringType(NuvoStatusCodes.PLAY_MODE.get(value));
846                 break;
847             case CHANNEL_TRACK_LENGTH:
848             case CHANNEL_TRACK_POSITION:
849                 state = new QuantityType<Time>(Integer.parseInt(value) / 10, NuvoHandler.API_SECOND_UNIT);
850                 break;
851             default:
852                 break;
853         }
854         updateState(channel, state);
855     }
856
857     /**
858      * Handle a button press from a UI Player item
859      *
860      * @param target the nuvo zone to receive the command
861      * @param command the button press command to send to the zone
862      */
863     private void handleControlCommand(NuvoEnum target, Command command) throws NuvoException {
864         if (command instanceof PlayPauseType) {
865             connector.sendCommand(target, NuvoCommand.PLAYPAUSE);
866         } else if (command instanceof NextPreviousType) {
867             if (command == NextPreviousType.NEXT) {
868                 connector.sendCommand(target, NuvoCommand.NEXT);
869             } else if (command == NextPreviousType.PREVIOUS) {
870                 connector.sendCommand(target, NuvoCommand.PREV);
871             }
872         } else {
873             logger.warn("Unknown control command: {}", command);
874         }
875     }
876 }