2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.nuvo.internal.handler;
15 import static org.eclipse.jetty.http.HttpMethod.GET;
16 import static org.eclipse.jetty.http.HttpStatus.OK_200;
17 import static org.openhab.binding.nuvo.internal.NuvoBindingConstants.*;
19 import java.io.StringReader;
20 import java.math.BigDecimal;
21 import java.text.SimpleDateFormat;
22 import java.util.ArrayList;
23 import java.util.Base64;
24 import java.util.Collection;
25 import java.util.Collections;
26 import java.util.Date;
27 import java.util.HashMap;
28 import java.util.HashSet;
29 import java.util.List;
31 import java.util.TreeMap;
32 import java.util.concurrent.ExecutionException;
33 import java.util.concurrent.ScheduledFuture;
34 import java.util.concurrent.TimeUnit;
35 import java.util.concurrent.TimeoutException;
36 import java.util.regex.Matcher;
37 import java.util.regex.Pattern;
38 import java.util.stream.Collectors;
39 import java.util.stream.IntStream;
41 import javax.measure.Unit;
42 import javax.measure.quantity.Time;
43 import javax.xml.bind.JAXBContext;
44 import javax.xml.bind.JAXBException;
45 import javax.xml.bind.Unmarshaller;
46 import javax.xml.stream.XMLStreamException;
47 import javax.xml.stream.XMLStreamReader;
49 import org.eclipse.jdt.annotation.NonNullByDefault;
50 import org.eclipse.jdt.annotation.Nullable;
51 import org.eclipse.jetty.client.HttpClient;
52 import org.eclipse.jetty.client.api.ContentResponse;
53 import org.openhab.binding.nuvo.internal.NuvoException;
54 import org.openhab.binding.nuvo.internal.NuvoStateDescriptionOptionProvider;
55 import org.openhab.binding.nuvo.internal.NuvoThingActions;
56 import org.openhab.binding.nuvo.internal.communication.NuvoCommand;
57 import org.openhab.binding.nuvo.internal.communication.NuvoConnector;
58 import org.openhab.binding.nuvo.internal.communication.NuvoDefaultConnector;
59 import org.openhab.binding.nuvo.internal.communication.NuvoEnum;
60 import org.openhab.binding.nuvo.internal.communication.NuvoImageResizer;
61 import org.openhab.binding.nuvo.internal.communication.NuvoIpConnector;
62 import org.openhab.binding.nuvo.internal.communication.NuvoMessageEvent;
63 import org.openhab.binding.nuvo.internal.communication.NuvoMessageEventListener;
64 import org.openhab.binding.nuvo.internal.communication.NuvoSerialConnector;
65 import org.openhab.binding.nuvo.internal.communication.NuvoStatusCodes;
66 import org.openhab.binding.nuvo.internal.configuration.NuvoThingConfiguration;
67 import org.openhab.binding.nuvo.internal.dto.JAXBUtils;
68 import org.openhab.binding.nuvo.internal.dto.NuvoMenu;
69 import org.openhab.binding.nuvo.internal.dto.NuvoMenu.Source.TopMenu;
70 import org.openhab.core.io.transport.serial.SerialPortManager;
71 import org.openhab.core.library.types.DecimalType;
72 import org.openhab.core.library.types.NextPreviousType;
73 import org.openhab.core.library.types.OnOffType;
74 import org.openhab.core.library.types.OpenClosedType;
75 import org.openhab.core.library.types.PercentType;
76 import org.openhab.core.library.types.PlayPauseType;
77 import org.openhab.core.library.types.QuantityType;
78 import org.openhab.core.library.types.StringType;
79 import org.openhab.core.library.unit.Units;
80 import org.openhab.core.thing.Channel;
81 import org.openhab.core.thing.ChannelUID;
82 import org.openhab.core.thing.Thing;
83 import org.openhab.core.thing.ThingStatus;
84 import org.openhab.core.thing.ThingStatusDetail;
85 import org.openhab.core.thing.binding.BaseThingHandler;
86 import org.openhab.core.thing.binding.ThingHandlerService;
87 import org.openhab.core.types.Command;
88 import org.openhab.core.types.State;
89 import org.openhab.core.types.StateOption;
90 import org.openhab.core.types.UnDefType;
91 import org.slf4j.Logger;
92 import org.slf4j.LoggerFactory;
95 * The {@link NuvoHandler} is responsible for handling commands, which are sent to one of the channels.
97 * Based on the Rotel binding by Laurent Garnier
99 * @author Michael Lobstein - Initial contribution
102 public class NuvoHandler extends BaseThingHandler implements NuvoMessageEventListener {
103 private static final long RECON_POLLING_INTERVAL_SEC = 60;
104 private static final long POLLING_INTERVAL_SEC = 30;
105 private static final long CLOCK_SYNC_INTERVAL_SEC = 3600;
106 private static final long INITIAL_POLLING_DELAY_SEC = 30;
107 private static final long INITIAL_CLOCK_SYNC_DELAY_SEC = 10;
108 private static final long PING_TIMEOUT_SEC = 60;
109 // spec says wait 50ms, min is 100
110 private static final long SLEEP_BETWEEN_CMD_MS = 100;
111 private static final Unit<Time> API_SECOND_UNIT = Units.SECOND;
113 private static final String ZONE = "ZONE";
114 private static final String SOURCE = "SOURCE";
115 private static final String CHANNEL_DELIMIT = "#";
116 private static final String UNDEF = "UNDEF";
117 private static final String GC_STR = "NV-I8G";
119 private static final int MAX_ZONES = 20;
120 private static final int MAX_SRC = 6;
121 private static final int MAX_FAV = 12;
122 private static final int MIN_VOLUME = 0;
123 private static final int MAX_VOLUME = 79;
124 private static final int MIN_EQ = -18;
125 private static final int MAX_EQ = 18;
127 private static final int MPS4_PORT = 5006;
129 private static final byte[] NO_ART = { 0 };
131 private static final Pattern ZONE_PATTERN = Pattern
132 .compile("^ON,SRC(\\d{1}),(MUTE|VOL\\d{1,2}),DND([0-1]),LOCK([0-1])$");
133 private static final Pattern DISP_PATTERN = Pattern.compile("^DISPLINE(\\d{1}),\"(.*)\"$");
134 private static final Pattern DISP_INFO_PATTERN = Pattern
135 .compile("^DISPINFO,DUR(\\d{1,6}),POS(\\d{1,6}),STATUS(\\d{1,2})$");
136 private static final Pattern ZONE_CFG_PATTERN = Pattern.compile("^BASS(.*),TREB(.*),BAL(.*),LOUDCMP([0-1])$");
138 private final Logger logger = LoggerFactory.getLogger(NuvoHandler.class);
139 private final NuvoStateDescriptionOptionProvider stateDescriptionProvider;
140 private final SerialPortManager serialPortManager;
141 private final HttpClient httpClient;
143 private @Nullable ScheduledFuture<?> reconnectJob;
144 private @Nullable ScheduledFuture<?> pollingJob;
145 private @Nullable ScheduledFuture<?> clockSyncJob;
146 private @Nullable ScheduledFuture<?> pingJob;
148 private NuvoConnector connector = new NuvoDefaultConnector();
149 private long lastEventReceived = System.currentTimeMillis();
150 private int numZones = 1;
151 private String versionString = BLANK;
152 private boolean isGConcerto = false;
153 private Object sequenceLock = new Object();
155 private boolean isAnyOhNuvoNet = false;
156 private NuvoMenu nuvoMenus = new NuvoMenu();
157 private HashMap<String, Integer> nuvoNetSrcMap = new HashMap<String, Integer>();
158 private HashMap<String, String> favPrefixMap = new HashMap<String, String>();
159 private HashMap<String, String[]> favoriteMap = new HashMap<String, String[]>();
161 private HashMap<String, byte[]> albumArtMap = new HashMap<String, byte[]>();
162 private HashMap<String, Integer> albumArtIds = new HashMap<String, Integer>();
163 private HashMap<String, String> dispInfoCache = new HashMap<String, String>();
165 Set<Integer> activeZones = new HashSet<>(1);
167 // A tree map that maps the source ids to source labels
168 TreeMap<String, String> sourceLabels = new TreeMap<String, String>();
170 // Indicates if there is a need to poll status because of a disconnection used for MPS4 systems
171 boolean pollStatusNeeded = true;
172 boolean isMps4 = false;
177 public NuvoHandler(Thing thing, NuvoStateDescriptionOptionProvider stateDescriptionProvider,
178 SerialPortManager serialPortManager, HttpClient httpClient) {
180 this.stateDescriptionProvider = stateDescriptionProvider;
181 this.serialPortManager = serialPortManager;
182 this.httpClient = httpClient;
186 public void initialize() {
187 final String uid = this.getThing().getUID().getAsString();
188 NuvoThingConfiguration config = getConfigAs(NuvoThingConfiguration.class);
189 final String serialPort = config.serialPort;
190 final String host = config.host;
191 final Integer port = config.port;
192 final Integer numZones = config.numZones;
194 // Check configuration settings
195 String configError = null;
196 if ((serialPort == null || serialPort.isEmpty()) && (host == null || host.isEmpty())) {
197 configError = "undefined serialPort and host configuration settings; please set one of them";
198 } else if (serialPort != null && (host == null || host.isEmpty())) {
199 if (serialPort.toLowerCase().startsWith("rfc2217")) {
200 configError = "use host and port configuration settings for a serial over IP connection";
204 configError = "undefined port configuration setting";
205 } else if (port <= 0) {
206 configError = "invalid port configuration setting";
210 if (configError != null) {
211 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configError);
215 if (serialPort != null && !serialPort.isEmpty()) {
216 connector = new NuvoSerialConnector(serialPortManager, serialPort, uid);
217 } else if (port != null) {
218 connector = new NuvoIpConnector(host, port, uid);
219 this.isMps4 = (port.intValue() == MPS4_PORT);
221 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
222 "Either Serial port or Host & Port must be specifed");
227 logger.debug("Port set to {} configuring binding for MPS4 compatability", MPS4_PORT);
229 this.isAnyOhNuvoNet = (config.nuvoNetSrc1 == 2 || config.nuvoNetSrc2 == 2 || config.nuvoNetSrc3 == 2
230 || config.nuvoNetSrc4 == 2 || config.nuvoNetSrc5 == 2 || config.nuvoNetSrc6 == 2);
232 if (this.isAnyOhNuvoNet) {
233 logger.debug("At least one source is configured as an openHAB NuvoNet source");
234 loadMenuConfiguration(config);
236 nuvoNetSrcMap.put("1", config.nuvoNetSrc1);
237 nuvoNetSrcMap.put("2", config.nuvoNetSrc2);
238 nuvoNetSrcMap.put("3", config.nuvoNetSrc3);
239 nuvoNetSrcMap.put("4", config.nuvoNetSrc4);
240 nuvoNetSrcMap.put("5", config.nuvoNetSrc5);
241 nuvoNetSrcMap.put("6", config.nuvoNetSrc6);
244 !config.favoritesSrc1.isEmpty() ? config.favoritesSrc1.split(COMMA) : new String[0]);
246 !config.favoritesSrc2.isEmpty() ? config.favoritesSrc2.split(COMMA) : new String[0]);
248 !config.favoritesSrc3.isEmpty() ? config.favoritesSrc3.split(COMMA) : new String[0]);
250 !config.favoritesSrc4.isEmpty() ? config.favoritesSrc4.split(COMMA) : new String[0]);
252 !config.favoritesSrc5.isEmpty() ? config.favoritesSrc5.split(COMMA) : new String[0]);
254 !config.favoritesSrc6.isEmpty() ? config.favoritesSrc6.split(COMMA) : new String[0]);
256 favPrefixMap.put("1", config.favPrefix1);
257 favPrefixMap.put("2", config.favPrefix2);
258 favPrefixMap.put("3", config.favPrefix3);
259 favPrefixMap.put("4", config.favPrefix4);
260 favPrefixMap.put("5", config.favPrefix5);
261 favPrefixMap.put("6", config.favPrefix6);
263 albumArtIds.put("S1", 0);
264 albumArtIds.put("S2", 0);
265 albumArtIds.put("S3", 0);
266 albumArtIds.put("S4", 0);
267 albumArtIds.put("S5", 0);
268 albumArtIds.put("S6", 0);
270 albumArtMap.put("S1", NO_ART);
271 albumArtMap.put("S2", NO_ART);
272 albumArtMap.put("S3", NO_ART);
273 albumArtMap.put("S4", NO_ART);
274 albumArtMap.put("S5", NO_ART);
275 albumArtMap.put("S6", NO_ART);
279 if (numZones != null) {
280 this.numZones = numZones;
283 activeZones = IntStream.range((1), (this.numZones + 1)).boxed().collect(Collectors.toSet());
285 // remove the channels for the zones we are not using
286 if (this.numZones < MAX_ZONES) {
287 List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
289 List<Integer> zonesToRemove = IntStream.range((this.numZones + 1), (MAX_ZONES + 1)).boxed()
290 .collect(Collectors.toList());
292 zonesToRemove.forEach(zone -> channels.removeIf(c -> (c.getUID().getId().contains("zone" + zone))));
293 updateThing(editThing().withChannels(channels).build());
296 // Build a list of State options for the global favorites using user config values (if supplied)
297 String[] favoritesArr = !config.favoriteLabels.isEmpty() ? config.favoriteLabels.split(COMMA) : new String[0];
298 List<StateOption> favoriteLabelsStateOptions = new ArrayList<>();
299 for (int i = 0; i < 12; i++) {
300 if (favoritesArr.length > i) {
301 favoriteLabelsStateOptions.add(new StateOption(String.valueOf(i + 1), favoritesArr[i]));
302 } else if (favoritesArr.length == 0) {
303 favoriteLabelsStateOptions.add(new StateOption(String.valueOf(i + 1), "Favorite " + (i + 1)));
307 // Put the global favorites labels on all active zones
308 activeZones.forEach(zoneNum -> {
309 stateDescriptionProvider.setStateOptions(
310 new ChannelUID(getThing().getUID(),
311 ZONE.toLowerCase() + zoneNum + CHANNEL_DELIMIT + CHANNEL_TYPE_FAVORITE),
312 favoriteLabelsStateOptions);
315 if (config.clockSync) {
316 scheduleClockSyncJob();
319 scheduleReconnectJob();
320 schedulePollingJob();
321 schedulePingTimeoutJob();
322 updateStatus(ThingStatus.UNKNOWN);
326 public void dispose() {
327 if (this.isAnyOhNuvoNet) {
329 // disable NuvoNet for each source that was configured as an openHAB NuvoNet source
330 nuvoNetSrcMap.forEach((srcNum, val) -> {
333 connector.sendCommand(SRC_KEY + srcNum + "DISPINFOTWO0,0,0,0,0,0,0");
334 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
335 connector.sendCommand(
336 SRC_KEY + srcNum + "DISPLINES0,0,0,\"Source Unavailable\",\"\",\"\",\"\"");
337 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
338 connector.sendCommand("SCFG" + srcNum + "NUVONET0");
339 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
340 } catch (NuvoException | InterruptedException e) {
341 logger.debug("Error sending command to disable NuvoNet source: {}", srcNum);
346 // need '1' flag for sources configured as an MPS4 NuvoNet source, but disable openHAB NuvoNet sources
347 connector.sendCommand("SNUMBERS" + (nuvoNetSrcMap.get("1") == 1 ? ONE : ZERO) + COMMA
348 + (nuvoNetSrcMap.get("2") == 1 ? ONE : ZERO) + COMMA
349 + (nuvoNetSrcMap.get("3") == 1 ? ONE : ZERO) + COMMA
350 + (nuvoNetSrcMap.get("4") == 1 ? ONE : ZERO) + COMMA
351 + (nuvoNetSrcMap.get("5") == 1 ? ONE : ZERO) + COMMA
352 + (nuvoNetSrcMap.get("6") == 1 ? ONE : ZERO));
353 } catch (NuvoException e) {
354 logger.debug("Error sending SNUMBERS command to disable NuvoNet sources");
358 cancelReconnectJob();
360 cancelClockSyncJob();
361 cancelPingTimeoutJob();
367 public Collection<Class<? extends ThingHandlerService>> getServices() {
368 return Collections.singletonList(NuvoThingActions.class);
371 public void handleRawCommand(@Nullable String command) {
372 synchronized (sequenceLock) {
374 connector.sendCommand(command);
375 } catch (NuvoException e) {
376 logger.warn("Nuvo Command: {} failed", command);
382 * Handle a command from the UI
384 * @param channelUID the channel sending the command
385 * @param command the command received
389 public void handleCommand(ChannelUID channelUID, Command command) {
390 String channel = channelUID.getId();
391 String[] channelSplit = channel.split(CHANNEL_DELIMIT);
392 NuvoEnum target = NuvoEnum.valueOf(channelSplit[0].toUpperCase());
394 String channelType = channelSplit[1];
396 if (getThing().getStatus() != ThingStatus.ONLINE) {
397 logger.debug("Thing is not ONLINE; command {} from channel {} is ignored", command, channel);
401 synchronized (sequenceLock) {
402 if (!connector.isConnected()) {
403 logger.warn("Command {} from channel {} is ignored: connection not established", command, channel);
408 switch (channelType) {
409 case CHANNEL_TYPE_POWER:
410 if (command instanceof OnOffType) {
411 connector.sendCommand(target, command == OnOffType.ON ? NuvoCommand.ON : NuvoCommand.OFF);
414 case CHANNEL_TYPE_SOURCE:
415 if (command instanceof DecimalType) {
416 int value = ((DecimalType) command).intValue();
417 if (value >= 1 && value <= MAX_SRC) {
418 logger.debug("Got source command {} zone {}", value, target);
419 connector.sendCommand(target, NuvoCommand.SOURCE, String.valueOf(value));
423 case CHANNEL_TYPE_FAVORITE:
424 if (command instanceof DecimalType) {
425 int value = ((DecimalType) command).intValue();
426 if (value >= 1 && value <= MAX_FAV) {
427 logger.debug("Got favorite command {} zone {}", value, target);
428 connector.sendCommand(target, NuvoCommand.FAVORITE, String.valueOf(value));
432 case CHANNEL_TYPE_VOLUME:
433 if (command instanceof PercentType) {
434 int value = (MAX_VOLUME
436 ((PercentType) command).doubleValue() / 100.0 * (MAX_VOLUME - MIN_VOLUME))
438 logger.debug("Got volume command {} zone {}", value, target);
439 connector.sendCommand(target, NuvoCommand.VOLUME, String.valueOf(value));
442 case CHANNEL_TYPE_MUTE:
443 if (command instanceof OnOffType) {
444 connector.sendCommand(target,
445 command == OnOffType.ON ? NuvoCommand.MUTE_ON : NuvoCommand.MUTE_OFF);
448 case CHANNEL_TYPE_TREBLE:
449 if (command instanceof DecimalType) {
450 int value = ((DecimalType) command).intValue();
451 if (value >= MIN_EQ && value <= MAX_EQ) {
452 // device can only accept even values
453 if (value % 2 == 1) {
456 logger.debug("Got treble command {} zone {}", value, target);
457 connector.sendCfgCommand(target, NuvoCommand.TREBLE, String.valueOf(value));
461 case CHANNEL_TYPE_BASS:
462 if (command instanceof DecimalType) {
463 int value = ((DecimalType) command).intValue();
464 if (value >= MIN_EQ && value <= MAX_EQ) {
465 if (value % 2 == 1) {
468 logger.debug("Got bass command {} zone {}", value, target);
469 connector.sendCfgCommand(target, NuvoCommand.BASS, String.valueOf(value));
473 case CHANNEL_TYPE_BALANCE:
474 if (command instanceof DecimalType) {
475 int value = ((DecimalType) command).intValue();
476 if (value >= MIN_EQ && value <= MAX_EQ) {
477 if (value % 2 == 1) {
480 logger.debug("Got balance command {} zone {}", value, target);
481 connector.sendCfgCommand(target, NuvoCommand.BALANCE,
482 NuvoStatusCodes.getBalanceFromInt(value));
486 case CHANNEL_TYPE_LOUDNESS:
487 if (command instanceof OnOffType) {
488 connector.sendCfgCommand(target, NuvoCommand.LOUDNESS,
489 command == OnOffType.ON ? ONE : ZERO);
492 case CHANNEL_TYPE_CONTROL:
493 handleControlCommand(target, command);
495 case CHANNEL_TYPE_DND:
496 if (command instanceof OnOffType) {
497 connector.sendCommand(target,
498 command == OnOffType.ON ? NuvoCommand.DND_ON : NuvoCommand.DND_OFF);
501 case CHANNEL_TYPE_PARTY:
502 if (command instanceof OnOffType) {
503 connector.sendCommand(target,
504 command == OnOffType.ON ? NuvoCommand.PARTY_ON : NuvoCommand.PARTY_OFF);
507 case CHANNEL_DISPLAY_LINE1:
508 if (command instanceof StringType) {
509 connector.sendCommand(target, NuvoCommand.DISPLINE1, "\"" + command + "\"");
512 case CHANNEL_DISPLAY_LINE2:
513 if (command instanceof StringType) {
514 connector.sendCommand(target, NuvoCommand.DISPLINE2, "\"" + command + "\"");
517 case CHANNEL_DISPLAY_LINE3:
518 if (command instanceof StringType) {
519 connector.sendCommand(target, NuvoCommand.DISPLINE3, "\"" + command + "\"");
522 case CHANNEL_DISPLAY_LINE4:
523 if (command instanceof StringType) {
524 connector.sendCommand(target, NuvoCommand.DISPLINE4, "\"" + command + "\"");
527 case CHANNEL_TYPE_ALLOFF:
528 if (command instanceof OnOffType) {
529 connector.sendCommand(NuvoCommand.ALLOFF);
532 case CHANNEL_TYPE_ALLMUTE:
533 if (command instanceof OnOffType) {
534 connector.sendCommand(
535 command == OnOffType.ON ? NuvoCommand.ALLMUTE_ON : NuvoCommand.ALLMUTE_OFF);
538 case CHANNEL_TYPE_PAGE:
539 if (command instanceof OnOffType) {
540 connector.sendCommand(command == OnOffType.ON ? NuvoCommand.PAGE_ON : NuvoCommand.PAGE_OFF);
543 case CHANNEL_TYPE_SENDCMD:
544 if (command instanceof StringType) {
545 String commandStr = command.toString();
546 if (commandStr.contains(DISP_INFO_TWO)) {
547 String sourceKey = commandStr.split(DISP_INFO_TWO)[0];
548 dispInfoCache.put(sourceKey, commandStr);
550 // if 'albumartid' is present, substitute it with the albumArtId hex string
551 connector.sendCommand(commandStr.replace(ALBUM_ART_ID,
552 (OFFSET_ZERO + Integer.toHexString(albumArtIds.get(sourceKey)))));
554 connector.sendCommand(commandStr);
558 case CHANNEL_ART_URL:
559 if (command instanceof StringType) {
560 String url = command.toString();
561 if (url.startsWith(HTTP) || url.startsWith(HTTPS)) {
563 ContentResponse contentResponse = httpClient.newRequest(url).method(GET)
564 .timeout(10, TimeUnit.SECONDS).send();
565 int httpStatus = contentResponse.getStatus();
566 if (httpStatus == OK_200) {
567 albumArtMap.put(target.getId(),
568 NuvoImageResizer.resizeImage(contentResponse.getContent(), 80, 80));
570 albumArtMap.put(target.getId(), NO_ART);
571 albumArtIds.put(target.getId(), 0);
574 } catch (InterruptedException | TimeoutException | ExecutionException e) {
575 albumArtMap.put(target.getId(), NO_ART);
576 albumArtIds.put(target.getId(), 0);
579 albumArtIds.put(target.getId(), Math.abs(url.hashCode()));
581 // re-send the cached DISPINFOTWO message, substituting in the new albumArtId
582 if (dispInfoCache.get(target.getId()) != null) {
583 connector.sendCommand(dispInfoCache.get(target.getId()).replace(ALBUM_ART_ID,
584 (OFFSET_ZERO + Integer.toHexString(albumArtIds.get(target.getId())))));
587 albumArtMap.put(target.getId(), NO_ART);
588 albumArtIds.put(target.getId(), 0);
592 } catch (NuvoException e) {
593 logger.warn("Command {} from channel {} failed: {}", command, channel, e.getMessage());
594 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Sending command failed");
596 scheduleReconnectJob();
602 * Open the connection with the Nuvo device
604 * @return true if the connection is opened successfully or false if not
606 private synchronized boolean openConnection() {
607 connector.addEventListener(this);
610 } catch (NuvoException e) {
611 logger.debug("openConnection() failed: {}", e.getMessage());
613 logger.debug("openConnection(): {}", connector.isConnected() ? "connected" : "disconnected");
614 return connector.isConnected();
618 * Close the connection with the Nuvo device
620 private synchronized void closeConnection() {
621 if (connector.isConnected()) {
623 connector.removeEventListener(this);
624 pollStatusNeeded = true;
625 logger.debug("closeConnection(): disconnected");
630 * Handle an event received from the Nuvo device
632 * @param event the event to process
635 public void onNewMessageEvent(NuvoMessageEvent evt) {
636 logger.debug("onNewMessageEvent: key {} = {}", evt.getKey(), evt.getValue());
637 lastEventReceived = System.currentTimeMillis();
639 String type = evt.getType();
640 String key = evt.getKey();
641 String updateData = evt.getValue().trim();
642 if (this.getThing().getStatus() != ThingStatus.ONLINE) {
643 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, this.versionString);
648 this.versionString = updateData;
649 // Determine if we are a Grand Concerto or not
650 if (this.versionString.contains(GC_STR)) {
651 logger.debug("Grand Concerto detected");
652 this.isGConcerto = true;
653 connector.setEssentia(false);
655 logger.debug("Grand Concerto not detected");
659 logger.debug("Restart message received; re-sending initialization messages");
660 enableNuvonet(false);
663 logger.debug("Ping message received- rescheduling ping timeout");
664 schedulePingTimeoutJob();
665 // Return here because receiving a ping does not indicate that one can poll
668 activeZones.forEach(zoneNum -> {
669 updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_POWER, OFF);
673 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_ALLMUTE, ONE.equals(updateData) ? ON : OFF);
674 activeZones.forEach(zoneNum -> {
675 updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_MUTE,
676 ONE.equals(updateData) ? ON : OFF);
680 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_PAGE, ONE.equals(updateData) ? ON : OFF);
682 case TYPE_SOURCE_UPDATE:
683 logger.debug("Source update: Source: {} - Value: {}", key, updateData);
684 NuvoEnum targetSource = NuvoEnum.valueOf(SOURCE + key);
686 if (updateData.contains(DISPLINE)) {
687 // example: DISPLINE2,"Play My Song (Featuring Dee Ajayi)"
688 Matcher matcher = DISP_PATTERN.matcher(updateData);
689 if (matcher.find()) {
690 updateChannelState(targetSource, CHANNEL_DISPLAY_LINE + matcher.group(1), matcher.group(2));
692 logger.debug("no match on message: {}", updateData);
694 } else if (updateData.contains(DISPINFO)) {
695 // example: DISPINFO,DUR0,POS70,STATUS2 (DUR and POS are expressed in tenths of a second)
696 // 6 places(tenths of a second)-> max 999,999 /10/60/60/24 = 1.15 days
697 Matcher matcher = DISP_INFO_PATTERN.matcher(updateData);
698 if (matcher.find()) {
699 updateChannelState(targetSource, CHANNEL_TRACK_LENGTH, matcher.group(1));
700 updateChannelState(targetSource, CHANNEL_TRACK_POSITION, matcher.group(2));
701 updateChannelState(targetSource, CHANNEL_PLAY_MODE, matcher.group(3));
703 logger.debug("no match on message: {}", updateData);
705 } else if (updateData.contains(NAME_QUOTE)) {
706 // example: NAME"Ipod"
707 String name = updateData.split("\"")[1];
708 sourceLabels.put(key, name);
711 case TYPE_ZONE_UPDATE:
712 logger.debug("Zone update: Zone: {} - Value: {}", key, updateData);
714 // or: ON,SRC3,VOL63,DND0,LOCK0
715 // or: ON,SRC3,MUTE,DND0,LOCK0
717 NuvoEnum targetZone = NuvoEnum.valueOf(ZONE + key);
719 if (OFF.equals(updateData)) {
720 updateChannelState(targetZone, CHANNEL_TYPE_POWER, OFF);
721 updateChannelState(targetZone, CHANNEL_TYPE_SOURCE, UNDEF);
723 Matcher matcher = ZONE_PATTERN.matcher(updateData);
724 if (matcher.find()) {
725 updateChannelState(targetZone, CHANNEL_TYPE_POWER, ON);
726 updateChannelState(targetZone, CHANNEL_TYPE_SOURCE, matcher.group(1));
728 if (MUTE.equals(matcher.group(2))) {
729 updateChannelState(targetZone, CHANNEL_TYPE_MUTE, ON);
731 updateChannelState(targetZone, CHANNEL_TYPE_MUTE, NuvoCommand.OFF.getValue());
732 updateChannelState(targetZone, CHANNEL_TYPE_VOLUME, matcher.group(2).replace(VOL, BLANK));
735 updateChannelState(targetZone, CHANNEL_TYPE_DND, ONE.equals(matcher.group(3)) ? ON : OFF);
736 updateChannelState(targetZone, CHANNEL_TYPE_LOCK, ONE.equals(matcher.group(4)) ? ON : OFF);
738 logger.debug("no match on message: {}", updateData);
742 case TYPE_ZONE_BUTTON:
743 logger.debug("Zone Button pressed: Source: {} - Button: {}", key, updateData);
744 updateChannelState(NuvoEnum.valueOf(SOURCE + key), CHANNEL_BUTTON_PRESS, updateData);
746 case TYPE_ZONE_BUTTON2:
747 String buttonAction = NuvoStatusCodes.BUTTON_CODE.get(updateData);
749 if (buttonAction != null) {
750 logger.debug("Zone NuvoNet Button pressed: Source: {} - Button: {}", key, buttonAction);
751 updateChannelState(NuvoEnum.valueOf(SOURCE + key), CHANNEL_BUTTON_PRESS, buttonAction);
753 logger.debug("Zone NuvoNet Button pressed: Source: {} - Unknown button code: {}", key, updateData);
754 updateChannelState(NuvoEnum.valueOf(SOURCE + key), CHANNEL_BUTTON_PRESS, updateData);
757 case TYPE_MENU_ITEM_SELECTED:
758 String[] updateDataSplit = updateData.split(COMMA);
759 String zoneSource = updateDataSplit[0];
760 String menuId = updateDataSplit[1];
761 int menuItemIdx = Integer.parseInt(updateDataSplit[2]) - 1;
763 boolean exitMenu = false;
764 if ("0xFFFFFFFF".equals(menuId)) {
765 TopMenu topMenuItem = nuvoMenus.getSource().get(Integer.parseInt(key) - 1).getTopMenu()
767 logger.debug("Top Menu item selected: Source: {} - Menu Item: {}", key, topMenuItem.getText());
768 updateChannelState(NuvoEnum.valueOf(SOURCE + key), CHANNEL_BUTTON_PRESS, topMenuItem.getText());
770 List<String> subMenuItems = topMenuItem.getItems();
772 if (subMenuItems.isEmpty()) {
775 // send submenu (maximum of 20 items)
776 int subMenuSize = subMenuItems.size() < 20 ? subMenuItems.size() : 20;
778 connector.sendCommand(zoneSource + "MENU" + (menuItemIdx + 11) + ",0,0," + subMenuSize
779 + ",0,0," + subMenuSize + ",\"" + topMenuItem.getText() + "\"");
780 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
782 for (int i = 0; i < subMenuSize; i++) {
783 connector.sendCommand(
784 zoneSource + "MENUITEM" + (i + 1) + ",0,0,\"" + subMenuItems.get(i) + "\"");
786 } catch (NuvoException | InterruptedException e) {
787 logger.debug("Error sending sub menu for {}", zoneSource);
791 // a sub menu item was selected
792 TopMenu topMenuItem = nuvoMenus.getSource().get(Integer.parseInt(key) - 1).getTopMenu()
793 .get(Integer.decode(menuId) - 11);
794 String subMenuItem = topMenuItem.getItems().get(menuItemIdx);
796 logger.debug("Sub Menu item selected: Source: {} - Menu Item: {}", key,
797 topMenuItem.getText() + "|" + subMenuItem);
798 updateChannelState(NuvoEnum.valueOf(SOURCE + key), CHANNEL_BUTTON_PRESS,
799 topMenuItem.getText() + "|" + subMenuItem);
805 // tell the zone to exit the menu
806 connector.sendCommand(zoneSource + "MENU0,0,0,0,0,0,0,\"\"");
807 } catch (NuvoException e) {
808 logger.debug("Error sending exit menu command for {}", zoneSource);
812 case TYPE_ZONE_MENUREQ:
813 logger.debug("Menu Request: Source: {} - Value: {}", key, updateData);
814 // For now we only support one level deep menus. If third field is '1', indicates go back to main menu.
815 String[] menuDataSplit = updateData.split(",");
816 if (menuDataSplit.length > 3 && ONE.equals(menuDataSplit[2])) {
818 connector.sendCommand(menuDataSplit[0] + "MENU0xFFFFFFFF,0,0,0,0,0,0,\"\"");
819 } catch (NuvoException e) {
820 logger.debug("Error sending main menu command for {}", menuDataSplit[0]);
825 case TYPE_ZONE_CONFIG:
826 logger.debug("Zone Configuration: Zone: {} - Value: {}", key, updateData);
827 // example: BASS1,TREB-2,BALR2,LOUDCMP1
828 Matcher matcher = ZONE_CFG_PATTERN.matcher(updateData);
829 if (matcher.find()) {
830 updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_BASS, matcher.group(1));
831 updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_TREBLE, matcher.group(2));
832 updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_BALANCE,
833 NuvoStatusCodes.getBalanceFromStr(matcher.group(3)));
834 updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_LOUDNESS,
835 ONE.equals(matcher.group(4)) ? ON : OFF);
837 logger.debug("no match on message: {}", updateData);
840 case TYPE_ALBUM_ART_REQ:
841 logger.debug("Album Art Request for Source: {} - Data: {}", key, updateData);
842 // 0x620FD879,80,80,2,0x00C0C0C0,0,0,0,0,1
843 String[] albumArtReq = updateData.split(COMMA);
844 albumArtIds.put(SRC_KEY + key, Integer.decode(albumArtReq[0]));
847 if (albumArtMap.get(SRC_KEY + key).length > 1) {
848 connector.sendCommand(SRC_KEY + key + ALBUM_ART_AVAILABLE + albumArtIds.get(SRC_KEY + key)
849 + COMMA + albumArtMap.get(SRC_KEY + key).length);
851 connector.sendCommand(SRC_KEY + key + ALBUM_ART_AVAILABLE + ZERO_COMMA);
853 } catch (NuvoException e) {
854 logger.debug("Error sending ALBUMARTAVAILABLE command for source: {}", key);
857 case TYPE_ALBUM_ART_FRAG_REQ:
858 logger.debug("Album Art Fragment Request for Source: {} - Data: {}", key, updateData);
859 // 0x620FD879,0,750 (id, requested offset from start of image, byte length requested)
860 String[] albumArtFragReq = updateData.split(COMMA);
861 int requestedId = Integer.decode(albumArtFragReq[0]);
862 int offset = Integer.parseInt(albumArtFragReq[1]);
863 int length = Integer.parseInt(albumArtFragReq[2]);
865 if (requestedId == albumArtIds.get(SRC_KEY + key)) {
866 byte[] chunk = new byte[length];
867 byte[] albumArtBytes = albumArtMap.get(SRC_KEY + key);
869 if (albumArtBytes != null) {
870 System.arraycopy(albumArtBytes, offset, chunk, 0, length);
871 final String frag = Base64.getEncoder().encodeToString(chunk);
873 connector.sendCommand(SRC_KEY + key + ALBUM_ART_FRAG + requestedId + COMMA + offset + COMMA
874 + frag.length() + COMMA + frag);
875 } catch (NuvoException e) {
876 logger.debug("Error sending ALBUMARTFRAG command for source: {}, artId: {}", key,
882 case TYPE_FAVORITE_REQ:
883 logger.debug("Favorite request for source: {} - favoriteId: {}", key, updateData);
885 int playlistIdx = Integer.parseInt(updateData, 16) - 1000;
886 updateChannelState(NuvoEnum.valueOf(SOURCE + key), CHANNEL_BUTTON_PRESS,
887 "PLAY_MUSIC_PRESET:" + favoriteMap.get(key)[playlistIdx]);
888 } catch (NumberFormatException nfe) {
889 logger.debug("Unable to parse favoriteId: {}", updateData);
893 logger.debug("onNewMessageEvent: unhandled key {}", key);
894 // Return here because receiving an unknown message does not indicate that one can poll
898 if (isMps4 && pollStatusNeeded) {
903 private void loadMenuConfiguration(NuvoThingConfiguration config) {
904 StringBuilder menuXml = new StringBuilder("<menu>");
906 if (!config.menuXmlSrc1.isEmpty()) {
907 menuXml.append("<source>" + config.menuXmlSrc1 + "</source>");
909 menuXml.append("<source/>");
911 if (!config.menuXmlSrc2.isEmpty()) {
912 menuXml.append("<source>" + config.menuXmlSrc2 + "</source>");
914 menuXml.append("<source/>");
916 if (!config.menuXmlSrc3.isEmpty()) {
917 menuXml.append("<source>" + config.menuXmlSrc3 + "</source>");
919 menuXml.append("<source/>");
921 if (!config.menuXmlSrc4.isEmpty()) {
922 menuXml.append("<source>" + config.menuXmlSrc4 + "</source>");
924 menuXml.append("<source/>");
926 if (!config.menuXmlSrc5.isEmpty()) {
927 menuXml.append("<source>" + config.menuXmlSrc5 + "</source>");
929 menuXml.append("<source/>");
931 if (!config.menuXmlSrc6.isEmpty()) {
932 menuXml.append("<source>" + config.menuXmlSrc6 + "</source>");
934 menuXml.append("<source/>");
936 menuXml.append("</menu>");
939 JAXBContext ctx = JAXBUtils.JAXBCONTEXT_NUVO_MENU;
941 Unmarshaller unmarshaller = ctx.createUnmarshaller();
942 if (unmarshaller != null) {
943 XMLStreamReader xsr = JAXBUtils.XMLINPUTFACTORY
944 .createXMLStreamReader(new StringReader(menuXml.toString()));
945 NuvoMenu menu = (NuvoMenu) unmarshaller.unmarshal(xsr);
952 logger.debug("No JAXBContext available to parse Nuvo Menu XML");
953 } catch (JAXBException | XMLStreamException e) {
954 logger.warn("Error processing Nuvo Menu XML: {}", e.getLocalizedMessage());
958 private void enableNuvonet(boolean showReady) {
959 if (!this.isAnyOhNuvoNet) {
963 // enable NuvoNet for each source configured as an openHAB NuvoNet source
964 nuvoNetSrcMap.forEach((srcNum, val) -> {
967 connector.sendCommand("SCFG" + srcNum + "NUVONET1");
968 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
969 } catch (NuvoException | InterruptedException e) {
970 logger.debug("Error sending SCFG command for source: {}", srcNum);
976 // set '1' flag for each source configured as an MPS4 NuvoNet source or openHAB NuvoNet source
977 connector.sendCommand("SNUMBERS" + (nuvoNetSrcMap.get("1") > 0 ? ONE : ZERO) + COMMA
978 + (nuvoNetSrcMap.get("2") > 0 ? ONE : ZERO) + COMMA + (nuvoNetSrcMap.get("3") > 0 ? ONE : ZERO)
979 + COMMA + (nuvoNetSrcMap.get("4") > 0 ? ONE : ZERO) + COMMA
980 + (nuvoNetSrcMap.get("5") > 0 ? ONE : ZERO) + COMMA + (nuvoNetSrcMap.get("6") > 0 ? ONE : ZERO));
981 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
982 } catch (NuvoException | InterruptedException e) {
983 logger.debug("Error sending SNUMBERS command");
986 // go though each source and if is openHAB NuvoNet then configure menu, favorites, etc.
987 nuvoNetSrcMap.forEach((srcNum, val) -> {
990 List<TopMenu> topMenuItems = nuvoMenus.getSource().get(Integer.parseInt(srcNum) - 1).getTopMenu();
992 if (!topMenuItems.isEmpty()) {
993 connector.sendCommand(
994 SRC_KEY + srcNum + "MENU," + (topMenuItems.size() < 10 ? topMenuItems.size() : 10));
995 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
997 for (int i = 0; i < (topMenuItems.size() < 10 ? topMenuItems.size() : 10); i++) {
998 connector.sendCommand(SRC_KEY + srcNum + "MENUITEM" + (i + 1) + ","
999 + (topMenuItems.get(i).getItems().isEmpty() ? ZERO : ONE) + ",0,\""
1000 + topMenuItems.get(i).getText() + "\"");
1001 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1005 String[] favorites = favoriteMap.get(srcNum);
1006 if (favorites != null) {
1007 connector.sendCommand(SRC_KEY + srcNum + "FAVORITES"
1008 + (favorites.length < 20 ? favorites.length : 20) + COMMA
1009 + ("1".equals(srcNum) ? ONE : ZERO) + COMMA + ("2".equals(srcNum) ? ONE : ZERO) + COMMA
1010 + ("3".equals(srcNum) ? ONE : ZERO) + COMMA + ("4".equals(srcNum) ? ONE : ZERO) + COMMA
1011 + ("5".equals(srcNum) ? ONE : ZERO) + COMMA + ("6".equals(srcNum) ? ONE : ZERO));
1012 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1014 for (int i = 0; i < (favorites.length < 20 ? favorites.length : 20); i++) {
1015 connector.sendCommand(SRC_KEY + srcNum + "FAVORITESITEM" + (i + 1000) + ",0,0,\""
1016 + favPrefixMap.get(srcNum) + favorites[i] + "\"");
1017 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1022 connector.sendCommand(SRC_KEY + srcNum + "DISPINFOTWO0,0,0,0,0,0,0");
1023 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1024 connector.sendCommand(SRC_KEY + srcNum + "DISPLINES0,0,0,\"Ready\",\"\",\"\",\"\"");
1025 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1028 } catch (NuvoException | InterruptedException e) {
1029 logger.debug("Error configuring NuvoNet for source: {}", srcNum);
1036 * Schedule the reconnection job
1038 private void scheduleReconnectJob() {
1039 logger.debug("Schedule reconnect job");
1040 cancelReconnectJob();
1041 reconnectJob = scheduler.scheduleWithFixedDelay(() -> {
1042 if (!connector.isConnected()) {
1043 logger.debug("Trying to reconnect...");
1045 if (openConnection()) {
1046 logger.debug("Reconnected");
1047 // Polling status will disconnect from MPS4 on reconnect
1051 enableNuvonet(true);
1053 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Reconnection failed");
1057 }, 1, RECON_POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
1061 * If a ping is not received within ping interval the connection is closed and a reconnect job is scheduled
1063 private void schedulePingTimeoutJob() {
1065 logger.debug("Schedule Ping Timeout job");
1066 cancelPingTimeoutJob();
1067 pingJob = scheduler.schedule(() -> {
1069 scheduleReconnectJob();
1070 }, PING_TIMEOUT_SEC, TimeUnit.SECONDS);
1072 logger.debug("Ping Timeout job not valid for serial connections");
1077 * Cancel the ping timeout job
1079 private void cancelPingTimeoutJob() {
1080 ScheduledFuture<?> pingJob = this.pingJob;
1081 if (pingJob != null) {
1082 pingJob.cancel(true);
1083 this.pingJob = null;
1087 private void pollStatus() {
1088 pollStatusNeeded = false;
1089 scheduler.submit(() -> {
1090 synchronized (sequenceLock) {
1092 connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
1094 NuvoEnum.VALID_SOURCES.forEach(source -> {
1096 connector.sendQuery(NuvoEnum.valueOf(source), NuvoCommand.NAME);
1097 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1098 connector.sendQuery(NuvoEnum.valueOf(source), NuvoCommand.DISPINFO);
1099 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1100 connector.sendQuery(NuvoEnum.valueOf(source), NuvoCommand.DISPLINE);
1101 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1102 } catch (NuvoException | InterruptedException e) {
1103 logger.debug("Error Querying Source data: {}", e.getMessage());
1107 // Query all active zones to get their current status and eq configuration
1108 activeZones.forEach(zoneNum -> {
1110 connector.sendQuery(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.STATUS);
1111 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1112 connector.sendCfgCommand(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.EQ_QUERY, BLANK);
1113 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1114 } catch (NuvoException | InterruptedException e) {
1115 logger.debug("Error Querying Zone data: {}", e.getMessage());
1119 List<StateOption> sourceStateOptions = new ArrayList<>();
1120 sourceLabels.keySet().forEach(key -> {
1121 sourceStateOptions.add(new StateOption(key, sourceLabels.get(key)));
1124 // Put the source labels on all active zones
1125 activeZones.forEach(zoneNum -> {
1126 stateDescriptionProvider.setStateOptions(
1127 new ChannelUID(getThing().getUID(),
1128 ZONE.toLowerCase() + zoneNum + CHANNEL_DELIMIT + CHANNEL_TYPE_SOURCE),
1129 sourceStateOptions);
1131 } catch (NuvoException e) {
1132 logger.debug("Error polling status from Nuvo: {}", e.getMessage());
1139 * Cancel the reconnection job
1141 private void cancelReconnectJob() {
1142 ScheduledFuture<?> reconnectJob = this.reconnectJob;
1143 if (reconnectJob != null) {
1144 reconnectJob.cancel(true);
1145 this.reconnectJob = null;
1150 * Schedule the polling job
1152 private void schedulePollingJob() {
1156 logger.debug("MPS4 doesn't support polling");
1159 logger.debug("Schedule polling job");
1162 // when the Nuvo amp is off, this will keep the connection (esp Serial over IP) alive and detect if the
1163 // connection goes down
1164 pollingJob = scheduler.scheduleWithFixedDelay(() -> {
1165 if (connector.isConnected()) {
1166 logger.debug("Polling the component for updated status...");
1168 synchronized (sequenceLock) {
1170 connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
1171 } catch (NuvoException e) {
1172 logger.debug("Polling error: {}", e.getMessage());
1175 // if the last event received was more than 1.25 intervals ago,
1176 // the component is not responding even though the connection is still good
1177 if ((System.currentTimeMillis() - lastEventReceived) > (POLLING_INTERVAL_SEC * 1.25 * 1000)) {
1178 logger.debug("Component not responding to status requests");
1179 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1180 "Component not responding to status requests");
1182 scheduleReconnectJob();
1186 }, INITIAL_POLLING_DELAY_SEC, POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
1190 * Cancel the polling job
1192 private void cancelPollingJob() {
1193 ScheduledFuture<?> pollingJob = this.pollingJob;
1194 if (pollingJob != null) {
1195 pollingJob.cancel(true);
1196 this.pollingJob = null;
1201 * Schedule the clock sync job
1203 private void scheduleClockSyncJob() {
1204 logger.debug("Schedule clock sync job");
1205 cancelClockSyncJob();
1206 clockSyncJob = scheduler.scheduleWithFixedDelay(() -> {
1207 if (this.isGConcerto) {
1209 SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy,MM,dd,HH,mm");
1210 connector.sendCommand(NuvoCommand.CFGTIME.getValue() + dateFormat.format(new Date()));
1211 } catch (NuvoException e) {
1212 logger.debug("Error syncing clock: {}", e.getMessage());
1215 this.cancelClockSyncJob();
1217 }, INITIAL_CLOCK_SYNC_DELAY_SEC, CLOCK_SYNC_INTERVAL_SEC, TimeUnit.SECONDS);
1221 * Cancel the clock sync job
1223 private void cancelClockSyncJob() {
1224 ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
1225 if (clockSyncJob != null) {
1226 clockSyncJob.cancel(true);
1227 this.clockSyncJob = null;
1232 * Update the state of a channel
1234 * @param target the channel group
1235 * @param channelType the channel group item
1236 * @param value the value to be updated
1238 private void updateChannelState(NuvoEnum target, String channelType, String value) {
1239 String channel = target.name().toLowerCase() + CHANNEL_DELIMIT + channelType;
1241 if (!isLinked(channel)) {
1245 State state = UnDefType.UNDEF;
1247 if (UNDEF.equals(value)) {
1248 updateState(channel, state);
1252 switch (channelType) {
1253 case CHANNEL_TYPE_POWER:
1254 case CHANNEL_TYPE_MUTE:
1255 case CHANNEL_TYPE_DND:
1256 case CHANNEL_TYPE_PARTY:
1257 case CHANNEL_TYPE_ALLMUTE:
1258 case CHANNEL_TYPE_PAGE:
1259 case CHANNEL_TYPE_LOUDNESS:
1260 state = ON.equals(value) ? OnOffType.ON : OnOffType.OFF;
1262 case CHANNEL_TYPE_LOCK:
1263 state = ON.equals(value) ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
1265 case CHANNEL_TYPE_SOURCE:
1266 case CHANNEL_TYPE_TREBLE:
1267 case CHANNEL_TYPE_BASS:
1268 case CHANNEL_TYPE_BALANCE:
1269 state = new DecimalType(value);
1271 case CHANNEL_TYPE_VOLUME:
1272 int volume = Integer.parseInt(value);
1273 long volumePct = Math
1274 .round((double) (MAX_VOLUME - volume) / (double) (MAX_VOLUME - MIN_VOLUME) * 100.0);
1275 state = new PercentType(BigDecimal.valueOf(volumePct));
1277 case CHANNEL_DISPLAY_LINE1:
1278 case CHANNEL_DISPLAY_LINE2:
1279 case CHANNEL_DISPLAY_LINE3:
1280 case CHANNEL_DISPLAY_LINE4:
1281 case CHANNEL_BUTTON_PRESS:
1282 state = new StringType(value);
1284 case CHANNEL_PLAY_MODE:
1285 state = new StringType(NuvoStatusCodes.PLAY_MODE.get(value));
1287 case CHANNEL_TRACK_LENGTH:
1288 case CHANNEL_TRACK_POSITION:
1289 state = new QuantityType<Time>(Integer.parseInt(value) / 10, NuvoHandler.API_SECOND_UNIT);
1294 updateState(channel, state);
1298 * Handle a button press from a UI Player item
1300 * @param target the nuvo zone to receive the command
1301 * @param command the button press command to send to the zone
1303 private void handleControlCommand(NuvoEnum target, Command command) throws NuvoException {
1304 if (command instanceof PlayPauseType) {
1305 connector.sendCommand(target, NuvoCommand.PLAYPAUSE);
1306 } else if (command instanceof NextPreviousType) {
1307 if (command == NextPreviousType.NEXT) {
1308 connector.sendCommand(target, NuvoCommand.NEXT);
1309 } else if (command == NextPreviousType.PREVIOUS) {
1310 connector.sendCommand(target, NuvoCommand.PREV);
1313 logger.warn("Unknown control command: {}", command);