]> git.basschouten.com Git - openhab-addons.git/blob
d57e77a548addc27896d776470a34ba1f0a2af4a
[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 && !serialPort.isEmpty()) {
186             connector = new NuvoSerialConnector(serialPortManager, serialPort, uid);
187         } else if (port != null) {
188             connector = new NuvoIpConnector(host, port, uid);
189             this.isMps4 = (port.intValue() == MPS4_PORT);
190             if (this.isMps4) {
191                 logger.debug("Port set to {} configuring binding for MPS4 compatability", MPS4_PORT);
192             }
193         } else {
194             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
195                     "Either Serial port or Host & Port must be specifed");
196             return;
197         }
198
199         if (numZones != null) {
200             this.numZones = numZones;
201         }
202
203         activeZones = IntStream.range((1), (this.numZones + 1)).boxed().collect(Collectors.toSet());
204
205         // remove the channels for the zones we are not using
206         if (this.numZones < MAX_ZONES) {
207             List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
208
209             List<Integer> zonesToRemove = IntStream.range((this.numZones + 1), (MAX_ZONES + 1)).boxed()
210                     .collect(Collectors.toList());
211
212             zonesToRemove.forEach(zone -> channels.removeIf(c -> (c.getUID().getId().contains("zone" + zone))));
213             updateThing(editThing().withChannels(channels).build());
214         }
215
216         if (config.clockSync) {
217             scheduleClockSyncJob();
218         }
219
220         scheduleReconnectJob();
221         schedulePollingJob();
222         schedulePingTimeoutJob();
223         updateStatus(ThingStatus.UNKNOWN);
224     }
225
226     @Override
227     public void dispose() {
228         cancelReconnectJob();
229         cancelPollingJob();
230         cancelClockSyncJob();
231         cancelPingTimeoutJob();
232         closeConnection();
233         super.dispose();
234     }
235
236     @Override
237     public Collection<Class<? extends ThingHandlerService>> getServices() {
238         return Collections.singletonList(NuvoThingActions.class);
239     }
240
241     public void handleRawCommand(@Nullable String command) {
242         synchronized (sequenceLock) {
243             try {
244                 connector.sendCommand(command);
245             } catch (NuvoException e) {
246                 logger.warn("Nuvo Command: {} failed", command);
247             }
248         }
249     }
250
251     /**
252      * Handle a command the UI
253      *
254      * @param channelUID the channel sending the command
255      * @param command the command received
256      *
257      */
258     @Override
259     public void handleCommand(ChannelUID channelUID, Command command) {
260         String channel = channelUID.getId();
261         String[] channelSplit = channel.split(CHANNEL_DELIMIT);
262         NuvoEnum target = NuvoEnum.valueOf(channelSplit[0].toUpperCase());
263
264         String channelType = channelSplit[1];
265
266         if (getThing().getStatus() != ThingStatus.ONLINE) {
267             logger.debug("Thing is not ONLINE; command {} from channel {} is ignored", command, channel);
268             return;
269         }
270
271         synchronized (sequenceLock) {
272             if (!connector.isConnected()) {
273                 logger.warn("Command {} from channel {} is ignored: connection not established", command, channel);
274                 return;
275             }
276
277             try {
278                 switch (channelType) {
279                     case CHANNEL_TYPE_POWER:
280                         if (command instanceof OnOffType) {
281                             connector.sendCommand(target, command == OnOffType.ON ? NuvoCommand.ON : NuvoCommand.OFF);
282                         }
283                         break;
284                     case CHANNEL_TYPE_SOURCE:
285                         if (command instanceof DecimalType) {
286                             int value = ((DecimalType) command).intValue();
287                             if (value >= 1 && value <= MAX_SRC) {
288                                 logger.debug("Got source command {} zone {}", value, target);
289                                 connector.sendCommand(target, NuvoCommand.SOURCE, String.valueOf(value));
290                             }
291                         }
292                         break;
293                     case CHANNEL_TYPE_FAVORITE:
294                         if (command instanceof DecimalType) {
295                             int value = ((DecimalType) command).intValue();
296                             if (value >= 1 && value <= MAX_FAV) {
297                                 logger.debug("Got favorite command {} zone {}", value, target);
298                                 connector.sendCommand(target, NuvoCommand.FAVORITE, String.valueOf(value));
299                             }
300                         }
301                         break;
302                     case CHANNEL_TYPE_VOLUME:
303                         if (command instanceof PercentType) {
304                             int value = (MAX_VOLUME
305                                     - (int) Math.round(
306                                             ((PercentType) command).doubleValue() / 100.0 * (MAX_VOLUME - MIN_VOLUME))
307                                     + MIN_VOLUME);
308                             logger.debug("Got volume command {} zone {}", value, target);
309                             connector.sendCommand(target, NuvoCommand.VOLUME, String.valueOf(value));
310                         }
311                         break;
312                     case CHANNEL_TYPE_MUTE:
313                         if (command instanceof OnOffType) {
314                             connector.sendCommand(target,
315                                     command == OnOffType.ON ? NuvoCommand.MUTE_ON : NuvoCommand.MUTE_OFF);
316                         }
317                         break;
318                     case CHANNEL_TYPE_TREBLE:
319                         if (command instanceof DecimalType) {
320                             int value = ((DecimalType) command).intValue();
321                             if (value >= MIN_EQ && value <= MAX_EQ) {
322                                 // device can only accept even values
323                                 if (value % 2 == 1) {
324                                     value++;
325                                 }
326                                 logger.debug("Got treble command {} zone {}", value, target);
327                                 connector.sendCfgCommand(target, NuvoCommand.TREBLE, String.valueOf(value));
328                             }
329                         }
330                         break;
331                     case CHANNEL_TYPE_BASS:
332                         if (command instanceof DecimalType) {
333                             int value = ((DecimalType) command).intValue();
334                             if (value >= MIN_EQ && value <= MAX_EQ) {
335                                 if (value % 2 == 1) {
336                                     value++;
337                                 }
338                                 logger.debug("Got bass command {} zone {}", value, target);
339                                 connector.sendCfgCommand(target, NuvoCommand.BASS, String.valueOf(value));
340                             }
341                         }
342                         break;
343                     case CHANNEL_TYPE_BALANCE:
344                         if (command instanceof DecimalType) {
345                             int value = ((DecimalType) command).intValue();
346                             if (value >= MIN_EQ && value <= MAX_EQ) {
347                                 if (value % 2 == 1) {
348                                     value++;
349                                 }
350                                 logger.debug("Got balance command {} zone {}", value, target);
351                                 connector.sendCfgCommand(target, NuvoCommand.BALANCE,
352                                         NuvoStatusCodes.getBalanceFromInt(value));
353                             }
354                         }
355                         break;
356                     case CHANNEL_TYPE_LOUDNESS:
357                         if (command instanceof OnOffType) {
358                             connector.sendCfgCommand(target, NuvoCommand.LOUDNESS,
359                                     command == OnOffType.ON ? ONE : ZERO);
360                         }
361                         break;
362                     case CHANNEL_TYPE_CONTROL:
363                         handleControlCommand(target, command);
364                         break;
365                     case CHANNEL_TYPE_DND:
366                         if (command instanceof OnOffType) {
367                             connector.sendCommand(target,
368                                     command == OnOffType.ON ? NuvoCommand.DND_ON : NuvoCommand.DND_OFF);
369                         }
370                         break;
371                     case CHANNEL_TYPE_PARTY:
372                         if (command instanceof OnOffType) {
373                             connector.sendCommand(target,
374                                     command == OnOffType.ON ? NuvoCommand.PARTY_ON : NuvoCommand.PARTY_OFF);
375                         }
376                         break;
377                     case CHANNEL_DISPLAY_LINE1:
378                         if (command instanceof StringType) {
379                             connector.sendCommand(target, NuvoCommand.DISPLINE1, "\"" + command + "\"");
380                         }
381                         break;
382                     case CHANNEL_DISPLAY_LINE2:
383                         if (command instanceof StringType) {
384                             connector.sendCommand(target, NuvoCommand.DISPLINE2, "\"" + command + "\"");
385                         }
386                         break;
387                     case CHANNEL_DISPLAY_LINE3:
388                         if (command instanceof StringType) {
389                             connector.sendCommand(target, NuvoCommand.DISPLINE3, "\"" + command + "\"");
390                         }
391                         break;
392                     case CHANNEL_DISPLAY_LINE4:
393                         if (command instanceof StringType) {
394                             connector.sendCommand(target, NuvoCommand.DISPLINE4, "\"" + command + "\"");
395                         }
396                         break;
397                     case CHANNEL_TYPE_ALLOFF:
398                         if (command instanceof OnOffType) {
399                             connector.sendCommand(NuvoCommand.ALLOFF);
400                         }
401                         break;
402                     case CHANNEL_TYPE_ALLMUTE:
403                         if (command instanceof OnOffType) {
404                             connector.sendCommand(
405                                     command == OnOffType.ON ? NuvoCommand.ALLMUTE_ON : NuvoCommand.ALLMUTE_OFF);
406                         }
407                         break;
408                     case CHANNEL_TYPE_PAGE:
409                         if (command instanceof OnOffType) {
410                             connector.sendCommand(command == OnOffType.ON ? NuvoCommand.PAGE_ON : NuvoCommand.PAGE_OFF);
411                         }
412                         break;
413                 }
414             } catch (NuvoException e) {
415                 logger.warn("Command {} from channel {} failed: {}", command, channel, e.getMessage());
416                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Sending command failed");
417                 closeConnection();
418                 scheduleReconnectJob();
419             }
420         }
421     }
422
423     /**
424      * Open the connection with the Nuvo device
425      *
426      * @return true if the connection is opened successfully or false if not
427      */
428     private synchronized boolean openConnection() {
429         connector.addEventListener(this);
430         try {
431             connector.open();
432         } catch (NuvoException e) {
433             logger.debug("openConnection() failed: {}", e.getMessage());
434         }
435         logger.debug("openConnection(): {}", connector.isConnected() ? "connected" : "disconnected");
436         return connector.isConnected();
437     }
438
439     /**
440      * Close the connection with the Nuvo device
441      */
442     private synchronized void closeConnection() {
443         if (connector.isConnected()) {
444             connector.close();
445             connector.removeEventListener(this);
446             pollStatusNeeded = true;
447             logger.debug("closeConnection(): disconnected");
448         }
449     }
450
451     /**
452      * Handle an event received from the Nuvo device
453      *
454      * @param event the event to process
455      */
456     @Override
457     public void onNewMessageEvent(NuvoMessageEvent evt) {
458         logger.debug("onNewMessageEvent: key {} = {}", evt.getKey(), evt.getValue());
459         lastEventReceived = System.currentTimeMillis();
460
461         String type = evt.getType();
462         String key = evt.getKey();
463         String updateData = evt.getValue().trim();
464         if (this.getThing().getStatus() != ThingStatus.ONLINE) {
465             updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, this.versionString);
466         }
467
468         switch (type) {
469             case TYPE_VERSION:
470                 this.versionString = updateData;
471                 // Determine if we are a Grand Concerto or not
472                 if (this.versionString.contains(GC_STR)) {
473                     logger.debug("Grand Concerto detected");
474                     this.isGConcerto = true;
475                     connector.setEssentia(false);
476                 } else {
477                     logger.debug("Grand Concerto not detected");
478                 }
479                 break;
480             case TYPE_PING:
481                 logger.debug("Ping message received- rescheduling ping timeout");
482                 schedulePingTimeoutJob();
483                 // Return here because receiving a ping does not indicate that one can poll
484                 return;
485             case TYPE_ALLOFF:
486                 activeZones.forEach(zoneNum -> {
487                     updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_POWER, OFF);
488                 });
489                 break;
490             case TYPE_ALLMUTE:
491                 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_ALLMUTE, ONE.equals(updateData) ? ON : OFF);
492                 activeZones.forEach(zoneNum -> {
493                     updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_MUTE,
494                             ONE.equals(updateData) ? ON : OFF);
495                 });
496                 break;
497             case TYPE_PAGE:
498                 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_PAGE, ONE.equals(updateData) ? ON : OFF);
499                 break;
500             case TYPE_SOURCE_UPDATE:
501                 logger.debug("Source update: Source: {} - Value: {}", key, updateData);
502                 NuvoEnum targetSource = NuvoEnum.valueOf(SOURCE + key);
503
504                 if (updateData.contains(DISPLINE)) {
505                     // example: DISPLINE2,"Play My Song (Featuring Dee Ajayi)"
506                     Matcher matcher = DISP_PATTERN.matcher(updateData);
507                     if (matcher.find()) {
508                         updateChannelState(targetSource, CHANNEL_DISPLAY_LINE + matcher.group(1), matcher.group(2));
509                     } else {
510                         logger.debug("no match on message: {}", updateData);
511                     }
512                 } else if (updateData.contains(DISPINFO)) {
513                     // example: DISPINFO,DUR0,POS70,STATUS2 (DUR and POS are expressed in tenths of a second)
514                     // 6 places(tenths of a second)-> max 999,999 /10/60/60/24 = 1.15 days
515                     Matcher matcher = DISP_INFO_PATTERN.matcher(updateData);
516                     if (matcher.find()) {
517                         updateChannelState(targetSource, CHANNEL_TRACK_LENGTH, matcher.group(1));
518                         updateChannelState(targetSource, CHANNEL_TRACK_POSITION, matcher.group(2));
519                         updateChannelState(targetSource, CHANNEL_PLAY_MODE, matcher.group(3));
520                     } else {
521                         logger.debug("no match on message: {}", updateData);
522                     }
523                 } else if (updateData.contains(NAME_QUOTE)) {
524                     // example: NAME"Ipod"
525                     String name = updateData.split("\"")[1];
526                     sourceLabels.put(key, name);
527                 }
528                 break;
529             case TYPE_ZONE_UPDATE:
530                 logger.debug("Zone update: Zone: {} - Value: {}", key, updateData);
531                 // example : OFF
532                 // or: ON,SRC3,VOL63,DND0,LOCK0
533                 // or: ON,SRC3,MUTE,DND0,LOCK0
534
535                 NuvoEnum targetZone = NuvoEnum.valueOf(ZONE + key);
536
537                 if (OFF.equals(updateData)) {
538                     updateChannelState(targetZone, CHANNEL_TYPE_POWER, OFF);
539                     updateChannelState(targetZone, CHANNEL_TYPE_SOURCE, UNDEF);
540                 } else {
541                     Matcher matcher = ZONE_PATTERN.matcher(updateData);
542                     if (matcher.find()) {
543                         updateChannelState(targetZone, CHANNEL_TYPE_POWER, ON);
544                         updateChannelState(targetZone, CHANNEL_TYPE_SOURCE, matcher.group(1));
545
546                         if (MUTE.equals(matcher.group(2))) {
547                             updateChannelState(targetZone, CHANNEL_TYPE_MUTE, ON);
548                         } else {
549                             updateChannelState(targetZone, CHANNEL_TYPE_MUTE, NuvoCommand.OFF.getValue());
550                             updateChannelState(targetZone, CHANNEL_TYPE_VOLUME, matcher.group(2).replace(VOL, BLANK));
551                         }
552
553                         updateChannelState(targetZone, CHANNEL_TYPE_DND, ONE.equals(matcher.group(3)) ? ON : OFF);
554                         updateChannelState(targetZone, CHANNEL_TYPE_LOCK, ONE.equals(matcher.group(4)) ? ON : OFF);
555                     } else {
556                         logger.debug("no match on message: {}", updateData);
557                     }
558                 }
559                 break;
560             case TYPE_ZONE_BUTTON:
561                 logger.debug("Zone Button pressed: Source: {} - Button: {}", key, updateData);
562                 updateChannelState(NuvoEnum.valueOf(SOURCE + key), CHANNEL_BUTTON_PRESS, updateData);
563                 break;
564             case TYPE_ZONE_CONFIG:
565                 logger.debug("Zone Configuration: Zone: {} - Value: {}", key, updateData);
566                 // example: BASS1,TREB-2,BALR2,LOUDCMP1
567                 Matcher matcher = ZONE_CFG_PATTERN.matcher(updateData);
568                 if (matcher.find()) {
569                     updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_BASS, matcher.group(1));
570                     updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_TREBLE, matcher.group(2));
571                     updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_BALANCE,
572                             NuvoStatusCodes.getBalanceFromStr(matcher.group(3)));
573                     updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_LOUDNESS,
574                             ONE.equals(matcher.group(4)) ? ON : OFF);
575                 } else {
576                     logger.debug("no match on message: {}", updateData);
577                 }
578                 break;
579             default:
580                 logger.debug("onNewMessageEvent: unhandled key {}", key);
581                 // Return here because receiving an unknown message does not indicate that one can poll
582                 return;
583         }
584
585         if (isMps4 && pollStatusNeeded) {
586             pollStatus();
587         }
588     }
589
590     /**
591      * Schedule the reconnection job
592      */
593     private void scheduleReconnectJob() {
594         logger.debug("Schedule reconnect job");
595         cancelReconnectJob();
596         reconnectJob = scheduler.scheduleWithFixedDelay(() -> {
597             if (!connector.isConnected()) {
598                 logger.debug("Trying to reconnect...");
599                 closeConnection();
600                 if (openConnection()) {
601                     logger.debug("Reconnected");
602                     // Polling status will disconnect from MPS4 on reconnect
603                     if (!isMps4) {
604                         pollStatus();
605                     }
606                 } else {
607                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Reconnection failed");
608                     closeConnection();
609                 }
610             }
611         }, 1, RECON_POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
612     }
613
614     /**
615      * If a ping is not received within ping interval the connection is closed and a reconnect job is scheduled
616      */
617     private void schedulePingTimeoutJob() {
618         if (isMps4) {
619             logger.debug("Schedule Ping Timeout job");
620             cancelPingTimeoutJob();
621             pingJob = scheduler.schedule(() -> {
622                 closeConnection();
623                 scheduleReconnectJob();
624             }, PING_TIMEOUT_SEC, TimeUnit.SECONDS);
625         } else {
626             logger.debug("Ping Timeout job not valid for serial connections");
627         }
628     }
629
630     /**
631      * Cancel the ping timeout job
632      */
633     private void cancelPingTimeoutJob() {
634         ScheduledFuture<?> pingJob = this.pingJob;
635         if (pingJob != null) {
636             pingJob.cancel(true);
637             this.pingJob = null;
638         }
639     }
640
641     private void pollStatus() {
642         pollStatusNeeded = false;
643         scheduler.submit(() -> {
644             synchronized (sequenceLock) {
645                 try {
646                     connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
647
648                     NuvoEnum.VALID_SOURCES.forEach(source -> {
649                         try {
650                             connector.sendQuery(NuvoEnum.valueOf(source), NuvoCommand.NAME);
651                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
652                             connector.sendQuery(NuvoEnum.valueOf(source), NuvoCommand.DISPINFO);
653                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
654                             connector.sendQuery(NuvoEnum.valueOf(source), NuvoCommand.DISPLINE);
655                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
656                         } catch (NuvoException | InterruptedException e) {
657                             logger.debug("Error Querying Source data: {}", e.getMessage());
658                         }
659                     });
660
661                     // Query all active zones to get their current status and eq configuration
662                     activeZones.forEach(zoneNum -> {
663                         try {
664                             connector.sendQuery(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.STATUS);
665                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
666                             connector.sendCfgCommand(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.EQ_QUERY, BLANK);
667                             Thread.sleep(SLEEP_BETWEEN_CMD_MS);
668                         } catch (NuvoException | InterruptedException e) {
669                             logger.debug("Error Querying Zone data: {}", e.getMessage());
670                         }
671                     });
672
673                     List<StateOption> sourceStateOptions = new ArrayList<>();
674                     sourceLabels.keySet().forEach(key -> {
675                         sourceStateOptions.add(new StateOption(key, sourceLabels.get(key)));
676                     });
677
678                     // Put the source labels on all active zones
679                     activeZones.forEach(zoneNum -> {
680                         stateDescriptionProvider.setStateOptions(
681                                 new ChannelUID(getThing().getUID(),
682                                         ZONE.toLowerCase() + zoneNum + CHANNEL_DELIMIT + CHANNEL_TYPE_SOURCE),
683                                 sourceStateOptions);
684                     });
685                 } catch (NuvoException e) {
686                     logger.debug("Error polling status from Nuvo: {}", e.getMessage());
687                 }
688             }
689         });
690     }
691
692     /**
693      * Cancel the reconnection job
694      */
695     private void cancelReconnectJob() {
696         ScheduledFuture<?> reconnectJob = this.reconnectJob;
697         if (reconnectJob != null) {
698             reconnectJob.cancel(true);
699             this.reconnectJob = null;
700         }
701     }
702
703     /**
704      * Schedule the polling job
705      */
706     private void schedulePollingJob() {
707         cancelPollingJob();
708
709         if (isMps4) {
710             logger.debug("MPS4 doesn't support polling");
711             return;
712         } else {
713             logger.debug("Schedule polling job");
714         }
715
716         // when the Nuvo amp is off, this will keep the connection (esp Serial over IP) alive and detect if the
717         // connection goes down
718         pollingJob = scheduler.scheduleWithFixedDelay(() -> {
719             if (connector.isConnected()) {
720                 logger.debug("Polling the component for updated status...");
721
722                 synchronized (sequenceLock) {
723                     try {
724                         connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
725                     } catch (NuvoException e) {
726                         logger.debug("Polling error: {}", e.getMessage());
727                     }
728
729                     // if the last event received was more than 1.25 intervals ago,
730                     // the component is not responding even though the connection is still good
731                     if ((System.currentTimeMillis() - lastEventReceived) > (POLLING_INTERVAL_SEC * 1.25 * 1000)) {
732                         logger.debug("Component not responding to status requests");
733                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
734                                 "Component not responding to status requests");
735                         closeConnection();
736                         scheduleReconnectJob();
737                     }
738                 }
739             }
740         }, INITIAL_POLLING_DELAY_SEC, POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
741     }
742
743     /**
744      * Cancel the polling job
745      */
746     private void cancelPollingJob() {
747         ScheduledFuture<?> pollingJob = this.pollingJob;
748         if (pollingJob != null) {
749             pollingJob.cancel(true);
750             this.pollingJob = null;
751         }
752     }
753
754     /**
755      * Schedule the clock sync job
756      */
757     private void scheduleClockSyncJob() {
758         logger.debug("Schedule clock sync job");
759         cancelClockSyncJob();
760         clockSyncJob = scheduler.scheduleWithFixedDelay(() -> {
761             if (this.isGConcerto) {
762                 try {
763                     connector.sendCommand(NuvoCommand.CFGTIME.getValue() + DATE_FORMAT.format(new Date()));
764                 } catch (NuvoException e) {
765                     logger.debug("Error syncing clock: {}", e.getMessage());
766                 }
767             } else {
768                 this.cancelClockSyncJob();
769             }
770         }, INITIAL_CLOCK_SYNC_DELAY_SEC, CLOCK_SYNC_INTERVAL_SEC, TimeUnit.SECONDS);
771     }
772
773     /**
774      * Cancel the clock sync job
775      */
776     private void cancelClockSyncJob() {
777         ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
778         if (clockSyncJob != null) {
779             clockSyncJob.cancel(true);
780             this.clockSyncJob = null;
781         }
782     }
783
784     /**
785      * Update the state of a channel
786      *
787      * @param target the channel group
788      * @param channelType the channel group item
789      * @param value the value to be updated
790      */
791     private void updateChannelState(NuvoEnum target, String channelType, String value) {
792         String channel = target.name().toLowerCase() + CHANNEL_DELIMIT + channelType;
793
794         if (!isLinked(channel)) {
795             return;
796         }
797
798         State state = UnDefType.UNDEF;
799
800         if (UNDEF.equals(value)) {
801             updateState(channel, state);
802             return;
803         }
804
805         switch (channelType) {
806             case CHANNEL_TYPE_POWER:
807             case CHANNEL_TYPE_MUTE:
808             case CHANNEL_TYPE_DND:
809             case CHANNEL_TYPE_PARTY:
810             case CHANNEL_TYPE_ALLMUTE:
811             case CHANNEL_TYPE_PAGE:
812             case CHANNEL_TYPE_LOUDNESS:
813                 state = ON.equals(value) ? OnOffType.ON : OnOffType.OFF;
814                 break;
815             case CHANNEL_TYPE_LOCK:
816                 state = ON.equals(value) ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
817                 break;
818             case CHANNEL_TYPE_SOURCE:
819             case CHANNEL_TYPE_TREBLE:
820             case CHANNEL_TYPE_BASS:
821             case CHANNEL_TYPE_BALANCE:
822                 state = new DecimalType(value);
823                 break;
824             case CHANNEL_TYPE_VOLUME:
825                 int volume = Integer.parseInt(value);
826                 long volumePct = Math
827                         .round((double) (MAX_VOLUME - volume) / (double) (MAX_VOLUME - MIN_VOLUME) * 100.0);
828                 state = new PercentType(BigDecimal.valueOf(volumePct));
829                 break;
830             case CHANNEL_DISPLAY_LINE1:
831             case CHANNEL_DISPLAY_LINE2:
832             case CHANNEL_DISPLAY_LINE3:
833             case CHANNEL_DISPLAY_LINE4:
834             case CHANNEL_BUTTON_PRESS:
835                 state = new StringType(value);
836                 break;
837             case CHANNEL_PLAY_MODE:
838                 state = new StringType(NuvoStatusCodes.PLAY_MODE.get(value));
839                 break;
840             case CHANNEL_TRACK_LENGTH:
841             case CHANNEL_TRACK_POSITION:
842                 state = new QuantityType<Time>(Integer.parseInt(value) / 10, NuvoHandler.API_SECOND_UNIT);
843                 break;
844             default:
845                 break;
846         }
847         updateState(channel, state);
848     }
849
850     /**
851      * Handle a button press from a UI Player item
852      *
853      * @param target the nuvo zone to receive the command
854      * @param command the button press command to send to the zone
855      */
856     private void handleControlCommand(NuvoEnum target, Command command) throws NuvoException {
857         if (command instanceof PlayPauseType) {
858             connector.sendCommand(target, NuvoCommand.PLAYPAUSE);
859         } else if (command instanceof NextPreviousType) {
860             if (command == NextPreviousType.NEXT) {
861                 connector.sendCommand(target, NuvoCommand.NEXT);
862             } else if (command == NextPreviousType.PREVIOUS) {
863                 connector.sendCommand(target, NuvoCommand.PREV);
864             }
865         } else {
866             logger.warn("Unknown control command: {}", command);
867         }
868     }
869 }