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