]> git.basschouten.com Git - openhab-addons.git/blob
a4b8e59235d7bcd9c7023ef8cfbde45f20178fb4
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 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.SmartHomeUnits;
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 = SmartHomeUnits.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                                 logger.debug("Got treble command {} zone {}", value, target);
312                                 connector.sendCfgCommand(target, NuvoCommand.TREBLE, String.valueOf(value));
313                             }
314                         }
315                         break;
316                     case CHANNEL_TYPE_BASS:
317                         if (command instanceof DecimalType) {
318                             int value = ((DecimalType) command).intValue();
319                             if (value >= MIN_EQ && value <= MAX_EQ) {
320                                 if (value % 2 == 1)
321                                     value++;
322                                 logger.debug("Got bass command {} zone {}", value, target);
323                                 connector.sendCfgCommand(target, NuvoCommand.BASS, String.valueOf(value));
324                             }
325                         }
326                         break;
327                     case CHANNEL_TYPE_BALANCE:
328                         if (command instanceof DecimalType) {
329                             int value = ((DecimalType) command).intValue();
330                             if (value >= MIN_EQ && value <= MAX_EQ) {
331                                 if (value % 2 == 1)
332                                     value++;
333                                 logger.debug("Got balance command {} zone {}", value, target);
334                                 connector.sendCfgCommand(target, NuvoCommand.BALANCE,
335                                         NuvoStatusCodes.getBalanceFromInt(value));
336                             }
337                         }
338                         break;
339                     case CHANNEL_TYPE_LOUDNESS:
340                         if (command instanceof OnOffType) {
341                             connector.sendCfgCommand(target, NuvoCommand.LOUDNESS,
342                                     command == OnOffType.ON ? ONE : ZERO);
343                         }
344                         break;
345                     case CHANNEL_TYPE_CONTROL:
346                         handleControlCommand(target, command);
347                         break;
348                     case CHANNEL_TYPE_DND:
349                         if (command instanceof OnOffType) {
350                             connector.sendCommand(target,
351                                     command == OnOffType.ON ? NuvoCommand.DND_ON : NuvoCommand.DND_OFF);
352                         }
353                         break;
354                     case CHANNEL_TYPE_PARTY:
355                         if (command instanceof OnOffType) {
356                             connector.sendCommand(target,
357                                     command == OnOffType.ON ? NuvoCommand.PARTY_ON : NuvoCommand.PARTY_OFF);
358                         }
359                         break;
360                     case CHANNEL_DISPLAY_LINE1:
361                         if (command instanceof StringType) {
362                             connector.sendCommand(target, NuvoCommand.DISPLINE1, "\"" + command + "\"");
363                         }
364                         break;
365                     case CHANNEL_DISPLAY_LINE2:
366                         if (command instanceof StringType) {
367                             connector.sendCommand(target, NuvoCommand.DISPLINE2, "\"" + command + "\"");
368                         }
369                         break;
370                     case CHANNEL_DISPLAY_LINE3:
371                         if (command instanceof StringType) {
372                             connector.sendCommand(target, NuvoCommand.DISPLINE3, "\"" + command + "\"");
373                         }
374                         break;
375                     case CHANNEL_DISPLAY_LINE4:
376                         if (command instanceof StringType) {
377                             connector.sendCommand(target, NuvoCommand.DISPLINE4, "\"" + command + "\"");
378                         }
379                         break;
380                     case CHANNEL_TYPE_ALLOFF:
381                         if (command instanceof OnOffType) {
382                             connector.sendCommand(NuvoCommand.ALLOFF);
383                         }
384                         break;
385                     case CHANNEL_TYPE_ALLMUTE:
386                         if (command instanceof OnOffType) {
387                             connector.sendCommand(
388                                     command == OnOffType.ON ? NuvoCommand.ALLMUTE_ON : NuvoCommand.ALLMUTE_OFF);
389                         }
390                         break;
391                     case CHANNEL_TYPE_PAGE:
392                         if (command instanceof OnOffType) {
393                             connector.sendCommand(command == OnOffType.ON ? NuvoCommand.PAGE_ON : NuvoCommand.PAGE_OFF);
394                         }
395                         break;
396                 }
397             } catch (NuvoException e) {
398                 logger.warn("Command {} from channel {} failed: {}", command, channel, e.getMessage());
399                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Sending command failed");
400                 closeConnection();
401                 scheduleReconnectJob();
402             }
403         }
404     }
405
406     /**
407      * Open the connection with the Nuvo device
408      *
409      * @return true if the connection is opened successfully or false if not
410      */
411     private synchronized boolean openConnection() {
412         connector.addEventListener(this);
413         try {
414             connector.open();
415         } catch (NuvoException e) {
416             logger.debug("openConnection() failed: {}", e.getMessage());
417         }
418         logger.debug("openConnection(): {}", connector.isConnected() ? "connected" : "disconnected");
419         return connector.isConnected();
420     }
421
422     /**
423      * Close the connection with the Nuvo device
424      */
425     private synchronized void closeConnection() {
426         if (connector.isConnected()) {
427             connector.close();
428             connector.removeEventListener(this);
429             logger.debug("closeConnection(): disconnected");
430         }
431     }
432
433     /**
434      * Handle an event received from the Nuvo device
435      *
436      * @param event the event to process
437      */
438     @Override
439     public void onNewMessageEvent(NuvoMessageEvent evt) {
440         logger.debug("onNewMessageEvent: key {} = {}", evt.getKey(), evt.getValue());
441         lastEventReceived = System.currentTimeMillis();
442
443         String type = evt.getType();
444         String key = evt.getKey();
445         String updateData = evt.getValue().trim();
446         if (this.getThing().getStatus() == ThingStatus.OFFLINE) {
447             updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, this.versionString);
448         }
449
450         switch (type) {
451             case TYPE_VERSION:
452                 this.versionString = updateData;
453                 // Determine if we are a Grand Concerto or not
454                 if (this.versionString.contains(GC_STR)) {
455                     this.isGConcerto = true;
456                     connector.setEssentia(false);
457                 }
458                 break;
459             case TYPE_ALLOFF:
460                 activeZones.forEach(zoneNum -> {
461                     updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_POWER, OFF);
462                 });
463                 break;
464             case TYPE_ALLMUTE:
465                 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_ALLMUTE, ONE.equals(updateData) ? ON : OFF);
466                 activeZones.forEach(zoneNum -> {
467                     updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_MUTE,
468                             ONE.equals(updateData) ? ON : OFF);
469                 });
470                 break;
471             case TYPE_PAGE:
472                 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_PAGE, ONE.equals(updateData) ? ON : OFF);
473                 break;
474             case TYPE_SOURCE_UPDATE:
475                 logger.debug("Source update: Source: {} - Value: {}", key, updateData);
476                 NuvoEnum targetSource = NuvoEnum.valueOf(SOURCE + key);
477
478                 if (updateData.contains(DISPLINE)) {
479                     // example: DISPLINE2,"Play My Song (Featuring Dee Ajayi)"
480                     Matcher matcher = DISP_PATTERN.matcher(updateData);
481                     if (matcher.find()) {
482                         updateChannelState(targetSource, CHANNEL_DISPLAY_LINE + matcher.group(1), matcher.group(2));
483                     } else {
484                         logger.debug("no match on message: {}", updateData);
485                     }
486                 } else if (updateData.contains(DISPINFO)) {
487                     // example: DISPINFO,DUR0,POS70,STATUS2 (DUR and POS are expressed in tenths of a second)
488                     // 6 places(tenths of a second)-> max 999,999 /10/60/60/24 = 1.15 days
489                     Matcher matcher = DISP_INFO_PATTERN.matcher(updateData);
490                     if (matcher.find()) {
491                         updateChannelState(targetSource, CHANNEL_TRACK_LENGTH, matcher.group(1));
492                         updateChannelState(targetSource, CHANNEL_TRACK_POSITION, matcher.group(2));
493                         updateChannelState(targetSource, CHANNEL_PLAY_MODE, matcher.group(3));
494                     } else {
495                         logger.debug("no match on message: {}", updateData);
496                     }
497                 } else if (updateData.contains(NAME_QUOTE)) {
498                     // example: NAME"Ipod"
499                     String name = updateData.split("\"")[1];
500                     sourceLabels.put(key, name);
501                 }
502                 break;
503             case TYPE_ZONE_UPDATE:
504                 logger.debug("Zone update: Zone: {} - Value: {}", key, updateData);
505                 // example : OFF
506                 // or: ON,SRC3,VOL63,DND0,LOCK0
507                 // or: ON,SRC3,MUTE,DND0,LOCK0
508
509                 NuvoEnum targetZone = NuvoEnum.valueOf(ZONE + key);
510
511                 if (OFF.equals(updateData)) {
512                     updateChannelState(targetZone, CHANNEL_TYPE_POWER, OFF);
513                     updateChannelState(targetZone, CHANNEL_TYPE_SOURCE, UNDEF);
514                 } else {
515                     Matcher matcher = ZONE_PATTERN.matcher(updateData);
516                     if (matcher.find()) {
517                         updateChannelState(targetZone, CHANNEL_TYPE_POWER, ON);
518                         updateChannelState(targetZone, CHANNEL_TYPE_SOURCE, matcher.group(1));
519
520                         if (MUTE.equals(matcher.group(2))) {
521                             updateChannelState(targetZone, CHANNEL_TYPE_MUTE, ON);
522                         } else {
523                             updateChannelState(targetZone, CHANNEL_TYPE_MUTE, NuvoCommand.OFF.getValue());
524                             updateChannelState(targetZone, CHANNEL_TYPE_VOLUME, matcher.group(2).replace(VOL, BLANK));
525                         }
526
527                         updateChannelState(targetZone, CHANNEL_TYPE_DND, ONE.equals(matcher.group(3)) ? ON : OFF);
528                         updateChannelState(targetZone, CHANNEL_TYPE_LOCK, ONE.equals(matcher.group(4)) ? ON : OFF);
529                     } else {
530                         logger.debug("no match on message: {}", updateData);
531                     }
532                 }
533                 break;
534             case TYPE_ZONE_BUTTON:
535                 logger.debug("Zone Button pressed: Source: {} - Button: {}", key, updateData);
536                 updateChannelState(NuvoEnum.valueOf(SOURCE + key), CHANNEL_BUTTON_PRESS, updateData);
537                 break;
538             case TYPE_ZONE_CONFIG:
539                 logger.debug("Zone Configuration: Zone: {} - Value: {}", key, updateData);
540                 // example: BASS1,TREB-2,BALR2,LOUDCMP1
541                 Matcher matcher = ZONE_CFG_PATTERN.matcher(updateData);
542                 if (matcher.find()) {
543                     updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_BASS, matcher.group(1));
544                     updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_TREBLE, matcher.group(2));
545                     updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_BALANCE,
546                             NuvoStatusCodes.getBalanceFromStr(matcher.group(3)));
547                     updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_LOUDNESS,
548                             ONE.equals(matcher.group(4)) ? ON : OFF);
549                 } else {
550                     logger.debug("no match on message: {}", updateData);
551                 }
552                 break;
553             default:
554                 logger.debug("onNewMessageEvent: unhandled key {}", key);
555                 break;
556         }
557     }
558
559     /**
560      * Schedule the reconnection job
561      */
562     private void scheduleReconnectJob() {
563         logger.debug("Schedule reconnect job");
564         cancelReconnectJob();
565         reconnectJob = scheduler.scheduleWithFixedDelay(() -> {
566             if (!connector.isConnected()) {
567                 logger.debug("Trying to reconnect...");
568                 closeConnection();
569                 String error = null;
570                 if (openConnection()) {
571                     synchronized (sequenceLock) {
572                         try {
573                             long prevUpdateTime = lastEventReceived;
574
575                             connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
576
577                             NuvoEnum.VALID_SOURCES.forEach(source -> {
578                                 try {
579                                     connector.sendQuery(NuvoEnum.valueOf(source), NuvoCommand.NAME);
580                                     Thread.sleep(SLEEP_BETWEEN_CMD_MS);
581                                     connector.sendQuery(NuvoEnum.valueOf(source), NuvoCommand.DISPINFO);
582                                     Thread.sleep(SLEEP_BETWEEN_CMD_MS);
583                                     connector.sendQuery(NuvoEnum.valueOf(source), NuvoCommand.DISPLINE);
584                                     Thread.sleep(SLEEP_BETWEEN_CMD_MS);
585                                 } catch (NuvoException | InterruptedException e) {
586                                     logger.debug("Error Querying Source data: {}", e.getMessage());
587                                 }
588                             });
589
590                             // Query all active zones to get their current status and eq configuration
591                             activeZones.forEach(zoneNum -> {
592                                 try {
593                                     connector.sendQuery(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.STATUS);
594                                     Thread.sleep(SLEEP_BETWEEN_CMD_MS);
595                                     connector.sendCfgCommand(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.EQ_QUERY,
596                                             BLANK);
597                                     Thread.sleep(SLEEP_BETWEEN_CMD_MS);
598                                 } catch (NuvoException | InterruptedException e) {
599                                     logger.debug("Error Querying Zone data: {}", e.getMessage());
600                                 }
601                             });
602
603                             // prevUpdateTime should have changed if a zone update was received
604                             if (prevUpdateTime == lastEventReceived) {
605                                 error = "Controller not responding to status requests";
606                             } else {
607                                 List<StateOption> sourceStateOptions = new ArrayList<>();
608                                 sourceLabels.keySet().forEach(key -> {
609                                     sourceStateOptions.add(new StateOption(key, sourceLabels.get(key)));
610                                 });
611
612                                 // Put the source labels on all active zones
613                                 activeZones.forEach(zoneNum -> {
614                                     stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(),
615                                             ZONE.toLowerCase() + zoneNum + CHANNEL_DELIMIT + CHANNEL_TYPE_SOURCE),
616                                             sourceStateOptions);
617                                 });
618                             }
619                         } catch (NuvoException e) {
620                             error = "First command after connection failed";
621                             logger.debug("{}: {}", error, e.getMessage());
622                         }
623                     }
624                 } else {
625                     error = "Reconnection failed";
626                 }
627                 if (error != null) {
628                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, error);
629                     closeConnection();
630                 } else {
631                     updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, this.versionString);
632                 }
633             }
634         }, 1, RECON_POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
635     }
636
637     /**
638      * Cancel the reconnection job
639      */
640     private void cancelReconnectJob() {
641         ScheduledFuture<?> reconnectJob = this.reconnectJob;
642         if (reconnectJob != null) {
643             reconnectJob.cancel(true);
644             this.reconnectJob = null;
645         }
646     }
647
648     /**
649      * Schedule the polling job
650      */
651     private void schedulePollingJob() {
652         logger.debug("Schedule polling job");
653         cancelPollingJob();
654
655         // when the Nuvo amp is off, this will keep the connection (esp Serial over IP) alive and detect if the
656         // connection goes down
657         pollingJob = scheduler.scheduleWithFixedDelay(() -> {
658             if (connector.isConnected()) {
659                 logger.debug("Polling the component for updated status...");
660
661                 synchronized (sequenceLock) {
662                     try {
663                         connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
664                     } catch (NuvoException e) {
665                         logger.debug("Polling error: {}", e.getMessage());
666                     }
667
668                     // if the last event received was more than 1.25 intervals ago,
669                     // the component is not responding even though the connection is still good
670                     if ((System.currentTimeMillis() - lastEventReceived) > (POLLING_INTERVAL_SEC * 1.25 * 1000)) {
671                         logger.debug("Component not responding to status requests");
672                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
673                                 "Component not responding to status requests");
674                         closeConnection();
675                         scheduleReconnectJob();
676                     }
677                 }
678             }
679         }, INITIAL_POLLING_DELAY_SEC, POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
680     }
681
682     /**
683      * Cancel the polling job
684      */
685     private void cancelPollingJob() {
686         ScheduledFuture<?> pollingJob = this.pollingJob;
687         if (pollingJob != null) {
688             pollingJob.cancel(true);
689             this.pollingJob = null;
690         }
691     }
692
693     /**
694      * Schedule the clock sync job
695      */
696     private void scheduleClockSyncJob() {
697         logger.debug("Schedule clock sync job");
698         cancelClockSyncJob();
699         clockSyncJob = scheduler.scheduleWithFixedDelay(() -> {
700             if (this.isGConcerto) {
701                 try {
702                     connector.sendCommand(NuvoCommand.CFGTIME.getValue() + DATE_FORMAT.format(new Date()));
703                 } catch (NuvoException e) {
704                     logger.debug("Error syncing clock: {}", e.getMessage());
705                 }
706             } else {
707                 this.cancelClockSyncJob();
708             }
709         }, INITIAL_CLOCK_SYNC_DELAY_SEC, CLOCK_SYNC_INTERVAL_SEC, TimeUnit.SECONDS);
710     }
711
712     /**
713      * Cancel the clock sync job
714      */
715     private void cancelClockSyncJob() {
716         ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
717         if (clockSyncJob != null) {
718             clockSyncJob.cancel(true);
719             this.clockSyncJob = null;
720         }
721     }
722
723     /**
724      * Update the state of a channel
725      *
726      * @param target the channel group
727      * @param channelType the channel group item
728      * @param value the value to be updated
729      */
730     private void updateChannelState(NuvoEnum target, String channelType, String value) {
731         String channel = target.name().toLowerCase() + CHANNEL_DELIMIT + channelType;
732
733         if (!isLinked(channel)) {
734             return;
735         }
736
737         State state = UnDefType.UNDEF;
738
739         if (UNDEF.equals(value)) {
740             updateState(channel, state);
741             return;
742         }
743
744         switch (channelType) {
745             case CHANNEL_TYPE_POWER:
746             case CHANNEL_TYPE_MUTE:
747             case CHANNEL_TYPE_DND:
748             case CHANNEL_TYPE_PARTY:
749             case CHANNEL_TYPE_ALLMUTE:
750             case CHANNEL_TYPE_PAGE:
751             case CHANNEL_TYPE_LOUDNESS:
752                 state = ON.equals(value) ? OnOffType.ON : OnOffType.OFF;
753                 break;
754             case CHANNEL_TYPE_LOCK:
755                 state = ON.equals(value) ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
756                 break;
757             case CHANNEL_TYPE_SOURCE:
758             case CHANNEL_TYPE_TREBLE:
759             case CHANNEL_TYPE_BASS:
760             case CHANNEL_TYPE_BALANCE:
761                 state = new DecimalType(value);
762                 break;
763             case CHANNEL_TYPE_VOLUME:
764                 int volume = Integer.parseInt(value);
765                 long volumePct = Math
766                         .round((double) (MAX_VOLUME - volume) / (double) (MAX_VOLUME - MIN_VOLUME) * 100.0);
767                 state = new PercentType(BigDecimal.valueOf(volumePct));
768                 break;
769             case CHANNEL_DISPLAY_LINE1:
770             case CHANNEL_DISPLAY_LINE2:
771             case CHANNEL_DISPLAY_LINE3:
772             case CHANNEL_DISPLAY_LINE4:
773             case CHANNEL_BUTTON_PRESS:
774                 state = new StringType(value);
775                 break;
776             case CHANNEL_PLAY_MODE:
777                 state = new StringType(NuvoStatusCodes.PLAY_MODE.get(value));
778                 break;
779             case CHANNEL_TRACK_LENGTH:
780             case CHANNEL_TRACK_POSITION:
781                 state = new QuantityType<Time>(Integer.parseInt(value) / 10, NuvoHandler.API_SECOND_UNIT);
782                 break;
783             default:
784                 break;
785         }
786         updateState(channel, state);
787     }
788
789     /**
790      * Handle a button press from a UI Player item
791      *
792      * @param target the nuvo zone to receive the command
793      * @param command the button press command to send to the zone
794      */
795     private void handleControlCommand(NuvoEnum target, Command command) throws NuvoException {
796         if (command instanceof PlayPauseType) {
797             connector.sendCommand(target, NuvoCommand.PLAYPAUSE);
798         } else if (command instanceof NextPreviousType) {
799             if (command == NextPreviousType.NEXT) {
800                 connector.sendCommand(target, NuvoCommand.NEXT);
801             } else if (command == NextPreviousType.PREVIOUS) {
802                 connector.sendCommand(target, NuvoCommand.PREV);
803             }
804         } else {
805             logger.warn("Unknown control command: {}", command);
806         }
807     }
808 }