2 * Copyright (c) 2010-2023 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.RawType;
79 import org.openhab.core.library.types.StringType;
80 import org.openhab.core.library.unit.Units;
81 import org.openhab.core.thing.Channel;
82 import org.openhab.core.thing.ChannelUID;
83 import org.openhab.core.thing.Thing;
84 import org.openhab.core.thing.ThingStatus;
85 import org.openhab.core.thing.ThingStatusDetail;
86 import org.openhab.core.thing.binding.BaseThingHandler;
87 import org.openhab.core.thing.binding.ThingHandlerService;
88 import org.openhab.core.types.Command;
89 import org.openhab.core.types.State;
90 import org.openhab.core.types.StateOption;
91 import org.openhab.core.types.UnDefType;
92 import org.slf4j.Logger;
93 import org.slf4j.LoggerFactory;
96 * The {@link NuvoHandler} is responsible for handling commands, which are sent to one of the channels.
98 * Based on the Rotel binding by Laurent Garnier
100 * @author Michael Lobstein - Initial contribution
103 public class NuvoHandler extends BaseThingHandler implements NuvoMessageEventListener {
104 private static final long RECON_POLLING_INTERVAL_SEC = 60;
105 private static final long POLLING_INTERVAL_SEC = 30;
106 private static final long CLOCK_SYNC_INTERVAL_SEC = 3600;
107 private static final long INITIAL_POLLING_DELAY_SEC = 30;
108 private static final long INITIAL_CLOCK_SYNC_DELAY_SEC = 10;
109 private static final long PING_TIMEOUT_SEC = 60;
110 // spec says wait 50ms, min is 100
111 private static final long SLEEP_BETWEEN_CMD_MS = 100;
112 private static final Unit<Time> API_SECOND_UNIT = Units.SECOND;
114 private static final String ZONE = "ZONE";
115 private static final String SOURCE = "SOURCE";
116 private static final String CHANNEL_DELIMIT = "#";
117 private static final String UNDEF = "UNDEF";
118 private static final String GC_STR = "NV-I8G";
120 private static final int MAX_ZONES = 20;
121 private static final int MAX_SRC = 6;
122 private static final int MAX_FAV = 12;
123 private static final int MIN_VOLUME = 0;
124 private static final int MAX_VOLUME = 79;
125 private static final int MIN_EQ = -18;
126 private static final int MAX_EQ = 18;
128 private static final int MPS4_PORT = 5006;
130 private static final byte[] NO_ART = { 0 };
132 private static final Pattern ZONE_PATTERN = Pattern
133 .compile("^ON,SRC(\\d{1}),(MUTE|VOL\\d{1,2}),DND([0-1]),LOCK([0-1])$");
134 private static final Pattern DISP_PATTERN = Pattern.compile("^DISPLINE(\\d{1}),\"(.*)\"$");
135 private static final Pattern DISP_INFO_PATTERN = Pattern
136 .compile("^DISPINFO,DUR(\\d{1,6}),POS(\\d{1,6}),STATUS(\\d{1,2})$");
137 private static final Pattern ZONE_CFG_EQ_PATTERN = Pattern.compile("^BASS(.*),TREB(.*),BAL(.*),LOUDCMP([0-1])$");
138 private static final Pattern ZONE_CFG_PATTERN = Pattern.compile(
139 "^ENABLE1,NAME\"(.*)\",SLAVETO(.*),GROUP([0-4]),SOURCES(.*),XSRC(.*),IR(.*),DND(.*),LOCKED(.*),SLAVEEQ(.*)$");
141 private final Logger logger = LoggerFactory.getLogger(NuvoHandler.class);
142 private final NuvoStateDescriptionOptionProvider stateDescriptionProvider;
143 private final SerialPortManager serialPortManager;
144 private final HttpClient httpClient;
146 private @Nullable ScheduledFuture<?> reconnectJob;
147 private @Nullable ScheduledFuture<?> pollingJob;
148 private @Nullable ScheduledFuture<?> clockSyncJob;
149 private @Nullable ScheduledFuture<?> pingJob;
151 private NuvoConnector connector = new NuvoDefaultConnector();
152 private long lastEventReceived = System.currentTimeMillis();
153 private int numZones = 1;
154 private String versionString = BLANK;
155 private boolean isGConcerto = false;
156 private Object sequenceLock = new Object();
158 private boolean isAnyOhNuvoNet = false;
159 private NuvoMenu nuvoMenus = new NuvoMenu();
160 private HashMap<String, Set<NuvoEnum>> nuvoGroupMap = new HashMap<String, Set<NuvoEnum>>();
161 private HashMap<String, Integer> nuvoNetSrcMap = new HashMap<String, Integer>();
162 private HashMap<String, String> favPrefixMap = new HashMap<String, String>();
163 private HashMap<String, String[]> favoriteMap = new HashMap<String, String[]>();
165 private HashMap<String, byte[]> albumArtMap = new HashMap<String, byte[]>();
166 private HashMap<String, Integer> albumArtIds = new HashMap<String, Integer>();
167 private HashMap<String, String> dispInfoCache = new HashMap<String, String>();
169 Set<Integer> activeZones = new HashSet<>(1);
171 // A tree map that maps the source ids to source labels
172 TreeMap<String, String> sourceLabels = new TreeMap<String, String>();
174 // Indicates if there is a need to poll status because of a disconnection used for MPS4 systems
175 boolean pollStatusNeeded = true;
176 boolean isMps4 = false;
181 public NuvoHandler(Thing thing, NuvoStateDescriptionOptionProvider stateDescriptionProvider,
182 SerialPortManager serialPortManager, HttpClient httpClient) {
184 this.stateDescriptionProvider = stateDescriptionProvider;
185 this.serialPortManager = serialPortManager;
186 this.httpClient = httpClient;
190 public void initialize() {
191 final String uid = this.getThing().getUID().getAsString();
192 NuvoThingConfiguration config = getConfigAs(NuvoThingConfiguration.class);
193 final String serialPort = config.serialPort;
194 final String host = config.host;
195 final Integer port = config.port;
196 final Integer numZones = config.numZones;
198 // Check configuration settings
199 String configError = null;
200 if ((serialPort == null || serialPort.isEmpty()) && (host == null || host.isEmpty())) {
201 configError = "undefined serialPort and host configuration settings; please set one of them";
202 } else if (serialPort != null && (host == null || host.isEmpty())) {
203 if (serialPort.toLowerCase().startsWith("rfc2217")) {
204 configError = "use host and port configuration settings for a serial over IP connection";
208 configError = "undefined port configuration setting";
209 } else if (port <= 0) {
210 configError = "invalid port configuration setting";
214 if (configError != null) {
215 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configError);
219 if (serialPort != null && !serialPort.isEmpty()) {
220 connector = new NuvoSerialConnector(serialPortManager, serialPort, uid);
221 } else if (host != null && port != null) {
222 connector = new NuvoIpConnector(host, port, uid);
223 this.isMps4 = (port.intValue() == MPS4_PORT);
225 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
226 "Either Serial port or Host & Port must be specifed");
230 nuvoNetSrcMap.put("1", config.nuvoNetSrc1);
231 nuvoNetSrcMap.put("2", config.nuvoNetSrc2);
232 nuvoNetSrcMap.put("3", config.nuvoNetSrc3);
233 nuvoNetSrcMap.put("4", config.nuvoNetSrc4);
234 nuvoNetSrcMap.put("5", config.nuvoNetSrc5);
235 nuvoNetSrcMap.put("6", config.nuvoNetSrc6);
237 nuvoGroupMap.put("1", new HashSet<NuvoEnum>());
238 nuvoGroupMap.put("2", new HashSet<NuvoEnum>());
239 nuvoGroupMap.put("3", new HashSet<NuvoEnum>());
240 nuvoGroupMap.put("4", new HashSet<NuvoEnum>());
243 logger.debug("Port set to {} configuring binding for MPS4 compatability", MPS4_PORT);
245 this.isAnyOhNuvoNet = (config.nuvoNetSrc1 == 2 || config.nuvoNetSrc2 == 2 || config.nuvoNetSrc3 == 2
246 || config.nuvoNetSrc4 == 2 || config.nuvoNetSrc5 == 2 || config.nuvoNetSrc6 == 2);
248 if (this.isAnyOhNuvoNet) {
249 logger.debug("At least one source is configured as an openHAB NuvoNet source");
250 connector.setAnyOhNuvoNet(true);
251 loadMenuConfiguration(config);
254 !config.favoritesSrc1.isEmpty() ? config.favoritesSrc1.split(COMMA) : new String[0]);
256 !config.favoritesSrc2.isEmpty() ? config.favoritesSrc2.split(COMMA) : new String[0]);
258 !config.favoritesSrc3.isEmpty() ? config.favoritesSrc3.split(COMMA) : new String[0]);
260 !config.favoritesSrc4.isEmpty() ? config.favoritesSrc4.split(COMMA) : new String[0]);
262 !config.favoritesSrc5.isEmpty() ? config.favoritesSrc5.split(COMMA) : new String[0]);
264 !config.favoritesSrc6.isEmpty() ? config.favoritesSrc6.split(COMMA) : new String[0]);
266 favPrefixMap.put("1", config.favPrefix1);
267 favPrefixMap.put("2", config.favPrefix2);
268 favPrefixMap.put("3", config.favPrefix3);
269 favPrefixMap.put("4", config.favPrefix4);
270 favPrefixMap.put("5", config.favPrefix5);
271 favPrefixMap.put("6", config.favPrefix6);
273 albumArtIds.put("S1", 0);
274 albumArtIds.put("S2", 0);
275 albumArtIds.put("S3", 0);
276 albumArtIds.put("S4", 0);
277 albumArtIds.put("S5", 0);
278 albumArtIds.put("S6", 0);
280 albumArtMap.put("S1", NO_ART);
281 albumArtMap.put("S2", NO_ART);
282 albumArtMap.put("S3", NO_ART);
283 albumArtMap.put("S4", NO_ART);
284 albumArtMap.put("S5", NO_ART);
285 albumArtMap.put("S6", NO_ART);
289 if (numZones != null) {
290 this.numZones = numZones;
293 activeZones = IntStream.range((1), (this.numZones + 1)).boxed().collect(Collectors.toSet());
295 // remove the channels for the zones we are not using
296 if (this.numZones < MAX_ZONES) {
297 List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
299 List<Integer> zonesToRemove = IntStream.range((this.numZones + 1), (MAX_ZONES + 1)).boxed()
300 .collect(Collectors.toList());
302 zonesToRemove.forEach(zone -> channels.removeIf(c -> (c.getUID().getId().contains("zone" + zone))));
303 updateThing(editThing().withChannels(channels).build());
306 // Build a list of State options for the global favorites using user config values (if supplied)
307 String[] favoritesArr = !config.favoriteLabels.isEmpty() ? config.favoriteLabels.split(COMMA) : new String[0];
308 List<StateOption> favoriteLabelsStateOptions = new ArrayList<>();
309 for (int i = 0; i < 12; i++) {
310 if (favoritesArr.length > i) {
311 favoriteLabelsStateOptions.add(new StateOption(String.valueOf(i + 1), favoritesArr[i]));
312 } else if (favoritesArr.length == 0) {
313 favoriteLabelsStateOptions.add(new StateOption(String.valueOf(i + 1), "Favorite " + (i + 1)));
317 // Put the global favorites labels on all active zones
318 activeZones.forEach(zoneNum -> {
319 stateDescriptionProvider.setStateOptions(
320 new ChannelUID(getThing().getUID(),
321 ZONE.toLowerCase() + zoneNum + CHANNEL_DELIMIT + CHANNEL_TYPE_FAVORITE),
322 favoriteLabelsStateOptions);
325 if (config.clockSync) {
326 scheduleClockSyncJob();
329 scheduleReconnectJob();
330 schedulePollingJob();
331 schedulePingTimeoutJob();
332 updateStatus(ThingStatus.UNKNOWN);
336 public void dispose() {
337 if (this.isAnyOhNuvoNet) {
339 // disable NuvoNet for each source that was configured as an openHAB NuvoNet source
340 nuvoNetSrcMap.forEach((srcNum, val) -> {
343 connector.sendCommand(SRC_KEY + srcNum + "DISPINFOTWO0,0,0,0,0,0,0");
344 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
345 connector.sendCommand(
346 SRC_KEY + srcNum + "DISPLINES0,0,0,\"Source Unavailable\",\"\",\"\",\"\"");
347 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
348 connector.sendCommand("SCFG" + srcNum + "NUVONET0");
349 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
350 } catch (NuvoException | InterruptedException e) {
351 logger.debug("Error sending command to disable NuvoNet source: {}", srcNum);
356 // need '1' flag for sources configured as an MPS4 NuvoNet source, but disable openHAB NuvoNet sources
357 connector.sendCommand("SNUMBERS" + (nuvoNetSrcMap.get("1") == 1 ? ONE : ZERO) + COMMA
358 + (nuvoNetSrcMap.get("2") == 1 ? ONE : ZERO) + COMMA
359 + (nuvoNetSrcMap.get("3") == 1 ? ONE : ZERO) + COMMA
360 + (nuvoNetSrcMap.get("4") == 1 ? ONE : ZERO) + COMMA
361 + (nuvoNetSrcMap.get("5") == 1 ? ONE : ZERO) + COMMA
362 + (nuvoNetSrcMap.get("6") == 1 ? ONE : ZERO));
363 } catch (NuvoException e) {
364 logger.debug("Error sending SNUMBERS command to disable NuvoNet sources");
368 cancelReconnectJob();
370 cancelClockSyncJob();
371 cancelPingTimeoutJob();
377 public Collection<Class<? extends ThingHandlerService>> getServices() {
378 return Collections.singletonList(NuvoThingActions.class);
381 public void handleRawCommand(@Nullable String command) {
382 synchronized (sequenceLock) {
384 connector.sendCommand(command);
385 } catch (NuvoException e) {
386 logger.warn("Nuvo Command: {} failed", command);
392 * Handle a command from the UI
394 * @param channelUID the channel sending the command
395 * @param command the command received
399 public void handleCommand(ChannelUID channelUID, Command command) {
400 String channel = channelUID.getId();
401 String[] channelSplit = channel.split(CHANNEL_DELIMIT);
402 NuvoEnum target = NuvoEnum.valueOf(channelSplit[0].toUpperCase());
404 String channelType = channelSplit[1];
406 if (getThing().getStatus() != ThingStatus.ONLINE) {
407 logger.debug("Thing is not ONLINE; command {} from channel {} is ignored", command, channel);
411 synchronized (sequenceLock) {
412 if (!connector.isConnected()) {
413 logger.warn("Command {} from channel {} is ignored: connection not established", command, channel);
418 switch (channelType) {
419 case CHANNEL_TYPE_POWER:
420 if (command instanceof OnOffType) {
421 connector.sendCommand(target, command == OnOffType.ON ? NuvoCommand.ON : NuvoCommand.OFF);
424 case CHANNEL_TYPE_SOURCE:
425 if (command instanceof DecimalType) {
426 int value = ((DecimalType) command).intValue();
427 if (value >= 1 && value <= MAX_SRC) {
428 logger.debug("Got source command {} zone {}", value, target);
429 connector.sendCommand(target, NuvoCommand.SOURCE, String.valueOf(value));
431 // update the other group member's selected source
432 updateSrcForZoneGroup(target, String.valueOf(value));
436 case CHANNEL_TYPE_FAVORITE:
437 if (command instanceof DecimalType) {
438 int value = ((DecimalType) command).intValue();
439 if (value >= 1 && value <= MAX_FAV) {
440 logger.debug("Got favorite command {} zone {}", value, target);
441 connector.sendCommand(target, NuvoCommand.FAVORITE, String.valueOf(value));
445 case CHANNEL_TYPE_VOLUME:
446 if (command instanceof PercentType) {
447 int value = (MAX_VOLUME
449 ((PercentType) command).doubleValue() / 100.0 * (MAX_VOLUME - MIN_VOLUME))
451 logger.debug("Got volume command {} zone {}", value, target);
452 connector.sendCommand(target, NuvoCommand.VOLUME, String.valueOf(value));
455 case CHANNEL_TYPE_MUTE:
456 if (command instanceof OnOffType) {
457 connector.sendCommand(target,
458 command == OnOffType.ON ? NuvoCommand.MUTE_ON : NuvoCommand.MUTE_OFF);
461 case CHANNEL_TYPE_TREBLE:
462 if (command instanceof DecimalType) {
463 int value = ((DecimalType) command).intValue();
464 if (value >= MIN_EQ && value <= MAX_EQ) {
465 // device can only accept even values
466 if (value % 2 == 1) {
469 logger.debug("Got treble command {} zone {}", value, target);
470 connector.sendCfgCommand(target, NuvoCommand.TREBLE, String.valueOf(value));
474 case CHANNEL_TYPE_BASS:
475 if (command instanceof DecimalType) {
476 int value = ((DecimalType) command).intValue();
477 if (value >= MIN_EQ && value <= MAX_EQ) {
478 if (value % 2 == 1) {
481 logger.debug("Got bass command {} zone {}", value, target);
482 connector.sendCfgCommand(target, NuvoCommand.BASS, String.valueOf(value));
486 case CHANNEL_TYPE_BALANCE:
487 if (command instanceof DecimalType) {
488 int value = ((DecimalType) command).intValue();
489 if (value >= MIN_EQ && value <= MAX_EQ) {
490 if (value % 2 == 1) {
493 logger.debug("Got balance command {} zone {}", value, target);
494 connector.sendCfgCommand(target, NuvoCommand.BALANCE,
495 NuvoStatusCodes.getBalanceFromInt(value));
499 case CHANNEL_TYPE_LOUDNESS:
500 if (command instanceof OnOffType) {
501 connector.sendCfgCommand(target, NuvoCommand.LOUDNESS,
502 command == OnOffType.ON ? ONE : ZERO);
505 case CHANNEL_TYPE_CONTROL:
506 handleControlCommand(target, command);
508 case CHANNEL_TYPE_DND:
509 if (command instanceof OnOffType) {
510 connector.sendCommand(target,
511 command == OnOffType.ON ? NuvoCommand.DND_ON : NuvoCommand.DND_OFF);
514 case CHANNEL_TYPE_PARTY:
515 if (command instanceof OnOffType) {
516 connector.sendCommand(target,
517 command == OnOffType.ON ? NuvoCommand.PARTY_ON : NuvoCommand.PARTY_OFF);
520 case CHANNEL_DISPLAY_LINE1:
521 if (command instanceof StringType) {
522 connector.sendCommand(target, NuvoCommand.DISPLINE1, "\"" + command + "\"");
525 case CHANNEL_DISPLAY_LINE2:
526 if (command instanceof StringType) {
527 connector.sendCommand(target, NuvoCommand.DISPLINE2, "\"" + command + "\"");
530 case CHANNEL_DISPLAY_LINE3:
531 if (command instanceof StringType) {
532 connector.sendCommand(target, NuvoCommand.DISPLINE3, "\"" + command + "\"");
535 case CHANNEL_DISPLAY_LINE4:
536 if (command instanceof StringType) {
537 connector.sendCommand(target, NuvoCommand.DISPLINE4, "\"" + command + "\"");
540 case CHANNEL_TYPE_ALLOFF:
541 if (command instanceof OnOffType) {
542 connector.sendCommand(NuvoCommand.ALLOFF);
545 case CHANNEL_TYPE_ALLMUTE:
546 if (command instanceof OnOffType) {
547 connector.sendCommand(
548 command == OnOffType.ON ? NuvoCommand.ALLMUTE_ON : NuvoCommand.ALLMUTE_OFF);
551 case CHANNEL_TYPE_PAGE:
552 if (command instanceof OnOffType) {
553 connector.sendCommand(command == OnOffType.ON ? NuvoCommand.PAGE_ON : NuvoCommand.PAGE_OFF);
556 case CHANNEL_TYPE_SENDCMD:
557 if (command instanceof StringType) {
558 String commandStr = command.toString();
559 if (commandStr.contains(DISP_INFO_TWO)) {
560 String sourceKey = commandStr.split(DISP_INFO_TWO)[0];
561 dispInfoCache.put(sourceKey, commandStr);
563 // if 'albumartid' is present, substitute it with the albumArtId hex string
564 connector.sendCommand(commandStr.replace(ALBUM_ART_ID,
565 (OFFSET_ZERO + Integer.toHexString(albumArtIds.get(sourceKey)))));
567 connector.sendCommand(commandStr);
571 case CHANNEL_ART_URL:
572 if (command instanceof StringType) {
573 String url = command.toString();
574 if (url.startsWith(HTTP) || url.startsWith(HTTPS)) {
576 ContentResponse contentResponse = httpClient.newRequest(url).method(GET)
577 .timeout(10, TimeUnit.SECONDS).send();
578 int httpStatus = contentResponse.getStatus();
579 if (httpStatus == OK_200) {
580 albumArtMap.put(target.getId(),
581 NuvoImageResizer.resizeImage(contentResponse.getContent(), 80, 80));
583 updateChannelState(target, CHANNEL_ALBUM_ART, BLANK,
584 contentResponse.getContent());
586 albumArtMap.put(target.getId(), NO_ART);
587 albumArtIds.put(target.getId(), 0);
588 updateChannelState(target, CHANNEL_ALBUM_ART, UNDEF);
591 } catch (InterruptedException | TimeoutException | ExecutionException e) {
592 albumArtMap.put(target.getId(), NO_ART);
593 albumArtIds.put(target.getId(), 0);
594 updateChannelState(target, CHANNEL_ALBUM_ART, UNDEF);
597 albumArtIds.put(target.getId(), Math.abs(url.hashCode()));
599 // re-send the cached DISPINFOTWO message, substituting in the new albumArtId
600 if (dispInfoCache.get(target.getId()) != null) {
601 connector.sendCommand(dispInfoCache.get(target.getId()).replace(ALBUM_ART_ID,
602 (OFFSET_ZERO + Integer.toHexString(albumArtIds.get(target.getId())))));
605 albumArtMap.put(target.getId(), NO_ART);
606 albumArtIds.put(target.getId(), 0);
607 updateChannelState(target, CHANNEL_ALBUM_ART, UNDEF);
611 } catch (NuvoException e) {
612 logger.warn("Command {} from channel {} failed: {}", command, channel, e.getMessage());
613 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Sending command failed");
615 scheduleReconnectJob();
621 * Open the connection with the Nuvo device
623 * @return true if the connection is opened successfully or false if not
625 private synchronized boolean openConnection() {
626 connector.addEventListener(this);
629 } catch (NuvoException e) {
630 logger.debug("openConnection() failed: {}", e.getMessage());
632 logger.debug("openConnection(): {}", connector.isConnected() ? "connected" : "disconnected");
633 return connector.isConnected();
637 * Close the connection with the Nuvo device
639 private synchronized void closeConnection() {
640 if (connector.isConnected()) {
642 connector.removeEventListener(this);
643 pollStatusNeeded = true;
644 logger.debug("closeConnection(): disconnected");
649 * Handle an event received from the Nuvo device
651 * @param event the event to process
654 public void onNewMessageEvent(NuvoMessageEvent evt) {
655 logger.debug("onNewMessageEvent: zone {}, source {}, value {}", evt.getZone(), evt.getSrc(), evt.getValue());
656 lastEventReceived = System.currentTimeMillis();
658 String type = evt.getType();
659 String zoneId = evt.getZone();
660 String srcId = evt.getSrc();
661 String updateData = evt.getValue().trim();
662 if (this.getThing().getStatus() != ThingStatus.ONLINE) {
663 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, this.versionString);
668 this.versionString = updateData;
669 // Determine if we are a Grand Concerto or not
670 if (this.versionString.contains(GC_STR)) {
671 logger.debug("Grand Concerto detected");
672 this.isGConcerto = true;
673 connector.setEssentia(false);
675 logger.debug("Grand Concerto not detected");
679 logger.debug("Restart message received; re-sending initialization messages");
680 enableNuvonet(false);
683 logger.debug("Ping message received- rescheduling ping timeout");
684 schedulePingTimeoutJob();
685 // Return here because receiving a ping does not indicate that one can poll
688 activeZones.forEach(zoneNum -> {
689 updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_POWER, OFF);
692 // Publish the ALLOFF event to all button channels for awareness in source rules
693 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, ZERO + COMMA + ALLOFF);
694 NuvoEnum.VALID_SOURCES.forEach(source -> {
695 updateChannelState(NuvoEnum.valueOf(source), CHANNEL_TYPE_BUTTONPRESS, ALLOFF);
700 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_ALLMUTE, ONE.equals(updateData) ? ON : OFF);
701 activeZones.forEach(zoneNum -> {
702 updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_MUTE,
703 ONE.equals(updateData) ? ON : OFF);
707 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_PAGE, ONE.equals(updateData) ? ON : OFF);
709 case TYPE_SOURCE_UPDATE:
710 logger.debug("Source update: Source: {} - Value: {}", srcId, updateData);
711 NuvoEnum targetSource = NuvoEnum.valueOf(SOURCE + srcId);
713 if (updateData.contains(DISPLINE)) {
714 // example: DISPLINE2,"Play My Song (Featuring Dee Ajayi)"
715 Matcher matcher = DISP_PATTERN.matcher(updateData);
716 if (matcher.find()) {
717 updateChannelState(targetSource, CHANNEL_DISPLAY_LINE + matcher.group(1), matcher.group(2));
719 logger.debug("no match on message: {}", updateData);
721 } else if (updateData.contains(DISPINFO)) {
722 // example: DISPINFO,DUR0,POS70,STATUS2 (DUR and POS are expressed in tenths of a second)
723 // 6 places(tenths of a second)-> max 999,999 /10/60/60/24 = 1.15 days
724 Matcher matcher = DISP_INFO_PATTERN.matcher(updateData);
725 if (matcher.find()) {
726 updateChannelState(targetSource, CHANNEL_TRACK_LENGTH, matcher.group(1));
727 updateChannelState(targetSource, CHANNEL_TRACK_POSITION, matcher.group(2));
728 updateChannelState(targetSource, CHANNEL_PLAY_MODE, matcher.group(3));
730 logger.debug("no match on message: {}", updateData);
732 } else if (updateData.contains(NAME_QUOTE)) {
733 // example: NAME"Ipod"
734 String name = updateData.split("\"")[1];
735 sourceLabels.put(srcId, name);
738 case TYPE_ZONE_UPDATE:
739 logger.debug("Zone update: Zone: {} - Value: {}", zoneId, updateData);
741 // or: ON,SRC3,VOL63,DND0,LOCK0
742 // or: ON,SRC3,MUTE,DND0,LOCK0
744 NuvoEnum targetZone = NuvoEnum.valueOf(ZONE + zoneId);
746 if (OFF.equals(updateData)) {
747 updateChannelState(targetZone, CHANNEL_TYPE_POWER, OFF);
748 updateChannelState(targetZone, CHANNEL_TYPE_SOURCE, UNDEF);
750 Matcher matcher = ZONE_PATTERN.matcher(updateData);
751 if (matcher.find()) {
752 updateChannelState(targetZone, CHANNEL_TYPE_POWER, ON);
753 updateChannelState(targetZone, CHANNEL_TYPE_SOURCE, matcher.group(1));
755 // update the other group member's selected source
756 updateSrcForZoneGroup(targetZone, matcher.group(1));
758 if (MUTE.equals(matcher.group(2))) {
759 updateChannelState(targetZone, CHANNEL_TYPE_MUTE, ON);
761 updateChannelState(targetZone, CHANNEL_TYPE_MUTE, NuvoCommand.OFF.getValue());
762 updateChannelState(targetZone, CHANNEL_TYPE_VOLUME, matcher.group(2).replace(VOL, BLANK));
765 updateChannelState(targetZone, CHANNEL_TYPE_DND, ONE.equals(matcher.group(3)) ? ON : OFF);
766 updateChannelState(targetZone, CHANNEL_TYPE_LOCK, ONE.equals(matcher.group(4)) ? ON : OFF);
768 logger.debug("no match on message: {}", updateData);
772 case TYPE_ZONE_SOURCE_BUTTON:
773 logger.debug("Source Button pressed: Source: {} - Button: {}", srcId, updateData);
774 updateChannelState(NuvoEnum.valueOf(SOURCE + srcId), CHANNEL_BUTTON_PRESS, updateData);
775 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, zoneId + COMMA + updateData);
778 String buttonAction = NuvoStatusCodes.BUTTON_CODE.get(updateData);
780 if (buttonAction != null) {
781 logger.debug("NuvoNet Source Button pressed: Source: {} - Button: {}", srcId, buttonAction);
782 updateChannelState(NuvoEnum.valueOf(SOURCE + srcId), CHANNEL_BUTTON_PRESS, buttonAction);
783 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, zoneId + COMMA + buttonAction);
785 logger.debug("NuvoNet Source Button pressed: Source: {} - Unknown button code: {}", srcId,
787 updateChannelState(NuvoEnum.valueOf(SOURCE + srcId), CHANNEL_BUTTON_PRESS, updateData);
788 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, zoneId + COMMA + updateData);
791 case TYPE_NN_MENU_ITEM_SELECTED:
792 // ignore this update unless openHAB is handling this source
793 if (nuvoNetSrcMap.get(srcId).equals(2)) {
794 String sourceZone = SRC_KEY + srcId + ZONE_KEY + zoneId;
795 String[] updateDataSplit = updateData.split(COMMA);
796 String menuId = updateDataSplit[0];
797 int menuItemIdx = Integer.parseInt(updateDataSplit[1]) - 1;
799 boolean exitMenu = false;
800 if ("0xFFFFFFFF".equals(menuId)) {
801 TopMenu topMenuItem = nuvoMenus.getSource().get(Integer.parseInt(srcId) - 1).getTopMenu()
803 logger.debug("Top Menu item selected: Source: {} - Menu Item: {}", srcId,
804 topMenuItem.getText());
805 updateChannelState(NuvoEnum.valueOf(SOURCE + srcId), CHANNEL_BUTTON_PRESS,
806 topMenuItem.getText());
807 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS,
808 zoneId + COMMA + topMenuItem.getText());
810 List<String> subMenuItems = topMenuItem.getItems();
812 if (subMenuItems.isEmpty()) {
815 // send submenu (maximum of 20 items)
816 int subMenuSize = subMenuItems.size() < 20 ? subMenuItems.size() : 20;
818 connector.sendCommand(sourceZone + "MENU" + (menuItemIdx + 11) + ",0,0," + subMenuSize
819 + ",0,0," + subMenuSize + ",\"" + topMenuItem.getText() + "\"");
820 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
822 for (int i = 0; i < subMenuSize; i++) {
823 connector.sendCommand(
824 sourceZone + "MENUITEM" + (i + 1) + ",0,0,\"" + subMenuItems.get(i) + "\"");
826 } catch (NuvoException | InterruptedException e) {
827 logger.debug("Error sending sub menu to {}", sourceZone);
831 // a sub menu item was selected
832 TopMenu topMenuItem = nuvoMenus.getSource().get(Integer.parseInt(srcId) - 1).getTopMenu()
833 .get(Integer.decode(menuId) - 11);
834 String subMenuItem = topMenuItem.getItems().get(menuItemIdx);
836 logger.debug("Sub Menu item selected: Source: {} - Menu Item: {}", srcId,
837 topMenuItem.getText() + "|" + subMenuItem);
838 updateChannelState(NuvoEnum.valueOf(SOURCE + srcId), CHANNEL_BUTTON_PRESS,
839 topMenuItem.getText() + "|" + subMenuItem);
840 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS,
841 zoneId + COMMA + topMenuItem.getText() + "|" + subMenuItem);
847 // tell the zone to exit the menu
848 connector.sendCommand(sourceZone + "MENU0,0,0,0,0,0,0,\"\"");
849 } catch (NuvoException e) {
850 logger.debug("Error sending exit menu command to {}", sourceZone);
855 case TYPE_NN_MENUREQ:
856 // ignore this update unless openHAB is handling this source
857 if (nuvoNetSrcMap.get(srcId).equals(2)) {
858 logger.debug("Menu Request: Source: {} - Value: {}", srcId, updateData);
859 String sourceZone = SRC_KEY + srcId + ZONE_KEY + zoneId;
860 // For now we only support one level deep menus. If second field is '1', indicates go back to main
862 String[] menuDataSplit = updateData.split(COMMA);
863 if (menuDataSplit.length > 2 && ONE.equals(menuDataSplit[1])) {
865 connector.sendCommand(sourceZone + "MENU0xFFFFFFFF,0,0,0,0,0,0,\"\"");
866 } catch (NuvoException e) {
867 logger.debug("Error sending main menu command to {}", sourceZone);
872 case TYPE_ZONE_CONFIG:
873 logger.debug("Zone Configuration: Zone: {} - Value: {}", zoneId, updateData);
874 // example: BASS1,TREB-2,BALR2,LOUDCMP1
875 Matcher matcher = ZONE_CFG_EQ_PATTERN.matcher(updateData);
876 if (matcher.find()) {
877 updateChannelState(NuvoEnum.valueOf(ZONE + zoneId), CHANNEL_TYPE_BASS, matcher.group(1));
878 updateChannelState(NuvoEnum.valueOf(ZONE + zoneId), CHANNEL_TYPE_TREBLE, matcher.group(2));
879 updateChannelState(NuvoEnum.valueOf(ZONE + zoneId), CHANNEL_TYPE_BALANCE,
880 NuvoStatusCodes.getBalanceFromStr(matcher.group(3)));
881 updateChannelState(NuvoEnum.valueOf(ZONE + zoneId), CHANNEL_TYPE_LOUDNESS,
882 ONE.equals(matcher.group(4)) ? ON : OFF);
884 matcher = ZONE_CFG_PATTERN.matcher(updateData);
885 // example: ENABLE1,NAME"Great Room",SLAVETO0,GROUP1,SOURCES63,XSRC0,IR1,DND0,LOCKED0,SLAVEEQ0
886 if (matcher.find()) {
887 // TODO: utilize other info such as zone name, available sources bitmask, etc.
889 // if this zone is a member of a group (1-4), add the zone's enum to the appropriate group map
890 if (!ZERO.equals(matcher.group(3))) {
891 nuvoGroupMap.get(matcher.group(3)).add(NuvoEnum.valueOf(ZONE + zoneId));
894 logger.debug("no match on message: {}", updateData);
898 case TYPE_NN_ALBUM_ART_REQ:
899 // ignore this update unless openHAB is handling this source
900 if (nuvoNetSrcMap.get(srcId).equals(2)) {
901 logger.debug("Album Art Request for Source: {} - Data: {}", srcId, updateData);
902 // 0x620FD879,80,80,2,0x00C0C0C0,0,0,0,0,1
903 String[] albumArtReq = updateData.split(COMMA);
904 albumArtIds.put(SRC_KEY + srcId, Integer.decode(albumArtReq[0]));
907 if (albumArtMap.get(SRC_KEY + srcId).length > 1) {
908 connector.sendCommand(
909 SRC_KEY + srcId + ALBUM_ART_AVAILABLE + albumArtIds.get(SRC_KEY + srcId) + COMMA
910 + albumArtMap.get(SRC_KEY + srcId).length);
912 connector.sendCommand(SRC_KEY + srcId + ALBUM_ART_AVAILABLE + ZERO_COMMA);
914 } catch (NuvoException e) {
915 logger.debug("Error sending ALBUMARTAVAILABLE command for source: {}", srcId);
919 case TYPE_NN_ALBUM_ART_FRAG_REQ:
920 // ignore this update unless openHAB is handling this source
921 if (nuvoNetSrcMap.get(srcId).equals(2)) {
922 logger.debug("Album Art Fragment Request for Source: {} - Data: {}", srcId, updateData);
923 // 0x620FD879,0,750 (id, requested offset from start of image, byte length requested)
924 String[] albumArtFragReq = updateData.split(COMMA);
925 int requestedId = Integer.decode(albumArtFragReq[0]);
926 int offset = Integer.parseInt(albumArtFragReq[1]);
927 int length = Integer.parseInt(albumArtFragReq[2]);
929 if (requestedId == albumArtIds.get(SRC_KEY + srcId)) {
930 byte[] chunk = new byte[length];
931 byte[] albumArtBytes = albumArtMap.get(SRC_KEY + srcId);
933 if (albumArtBytes != null) {
934 System.arraycopy(albumArtBytes, offset, chunk, 0, length);
935 final String frag = Base64.getEncoder().encodeToString(chunk);
937 connector.sendCommand(SRC_KEY + srcId + ALBUM_ART_FRAG + requestedId + COMMA + offset
938 + COMMA + frag.length() + COMMA + frag);
939 } catch (NuvoException e) {
940 logger.debug("Error sending ALBUMARTFRAG command for source: {}, artId: {}", srcId,
947 case TYPE_NN_FAVORITE_REQ:
948 // ignore this update unless openHAB is handling this source
949 if (nuvoNetSrcMap.get(srcId).equals(2)) {
950 logger.debug("Favorite request for source: {} - favoriteId: {}", srcId, updateData);
952 int playlistIdx = Integer.parseInt(updateData, 16) - 1000;
953 updateChannelState(NuvoEnum.valueOf(SOURCE + srcId), CHANNEL_BUTTON_PRESS,
954 "PLAY_MUSIC_PRESET:" + favoriteMap.get(srcId)[playlistIdx]);
955 } catch (NumberFormatException nfe) {
956 logger.debug("Unable to parse favoriteId: {}", updateData);
961 logger.debug("onNewMessageEvent: unhandled event type {}", type);
962 // Return here because receiving an unknown message does not indicate that one can poll
966 if (isMps4 && pollStatusNeeded) {
971 private void loadMenuConfiguration(NuvoThingConfiguration config) {
972 StringBuilder menuXml = new StringBuilder("<menu>");
974 if (!config.menuXmlSrc1.isEmpty()) {
975 menuXml.append("<source>" + config.menuXmlSrc1 + "</source>");
977 menuXml.append("<source/>");
979 if (!config.menuXmlSrc2.isEmpty()) {
980 menuXml.append("<source>" + config.menuXmlSrc2 + "</source>");
982 menuXml.append("<source/>");
984 if (!config.menuXmlSrc3.isEmpty()) {
985 menuXml.append("<source>" + config.menuXmlSrc3 + "</source>");
987 menuXml.append("<source/>");
989 if (!config.menuXmlSrc4.isEmpty()) {
990 menuXml.append("<source>" + config.menuXmlSrc4 + "</source>");
992 menuXml.append("<source/>");
994 if (!config.menuXmlSrc5.isEmpty()) {
995 menuXml.append("<source>" + config.menuXmlSrc5 + "</source>");
997 menuXml.append("<source/>");
999 if (!config.menuXmlSrc6.isEmpty()) {
1000 menuXml.append("<source>" + config.menuXmlSrc6 + "</source>");
1002 menuXml.append("<source/>");
1004 menuXml.append("</menu>");
1007 JAXBContext ctx = JAXBUtils.JAXBCONTEXT_NUVO_MENU;
1009 Unmarshaller unmarshaller = ctx.createUnmarshaller();
1010 if (unmarshaller != null) {
1011 XMLStreamReader xsr = JAXBUtils.XMLINPUTFACTORY
1012 .createXMLStreamReader(new StringReader(menuXml.toString()));
1013 NuvoMenu menu = (NuvoMenu) unmarshaller.unmarshal(xsr);
1020 logger.debug("No JAXBContext available to parse Nuvo Menu XML");
1021 } catch (JAXBException | XMLStreamException e) {
1022 logger.warn("Error processing Nuvo Menu XML: {}", e.getLocalizedMessage());
1026 private void enableNuvonet(boolean showReady) {
1027 if (!this.isAnyOhNuvoNet) {
1031 // enable NuvoNet for each source configured as an openHAB NuvoNet source
1032 nuvoNetSrcMap.forEach((srcNum, val) -> {
1035 connector.sendCommand("SCFG" + srcNum + "NUVONET1");
1036 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1037 } catch (NuvoException | InterruptedException e) {
1038 logger.debug("Error sending SCFG command for source: {}", srcNum);
1044 // set '1' flag for each source configured as an MPS4 NuvoNet source or openHAB NuvoNet source
1045 connector.sendCommand("SNUMBERS" + (nuvoNetSrcMap.get("1") > 0 ? ONE : ZERO) + COMMA
1046 + (nuvoNetSrcMap.get("2") > 0 ? ONE : ZERO) + COMMA + (nuvoNetSrcMap.get("3") > 0 ? ONE : ZERO)
1047 + COMMA + (nuvoNetSrcMap.get("4") > 0 ? ONE : ZERO) + COMMA
1048 + (nuvoNetSrcMap.get("5") > 0 ? ONE : ZERO) + COMMA + (nuvoNetSrcMap.get("6") > 0 ? ONE : ZERO));
1049 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1050 } catch (NuvoException | InterruptedException e) {
1051 logger.debug("Error sending SNUMBERS command");
1054 // go though each source and if is openHAB NuvoNet then configure menu, favorites, etc.
1055 nuvoNetSrcMap.forEach((srcNum, val) -> {
1058 List<TopMenu> topMenuItems = nuvoMenus.getSource().get(Integer.parseInt(srcNum) - 1).getTopMenu();
1060 if (!topMenuItems.isEmpty()) {
1061 connector.sendCommand(
1062 SRC_KEY + srcNum + "MENU," + (topMenuItems.size() < 10 ? topMenuItems.size() : 10));
1063 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1065 for (int i = 0; i < (topMenuItems.size() < 10 ? topMenuItems.size() : 10); i++) {
1066 connector.sendCommand(SRC_KEY + srcNum + "MENUITEM" + (i + 1) + ","
1067 + (topMenuItems.get(i).getItems().isEmpty() ? ZERO : ONE) + ",0,\""
1068 + topMenuItems.get(i).getText() + "\"");
1069 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1073 String[] favorites = favoriteMap.get(srcNum);
1074 if (favorites != null) {
1075 connector.sendCommand(SRC_KEY + srcNum + "FAVORITES"
1076 + (favorites.length < 20 ? favorites.length : 20) + COMMA
1077 + ("1".equals(srcNum) ? ONE : ZERO) + COMMA + ("2".equals(srcNum) ? ONE : ZERO) + COMMA
1078 + ("3".equals(srcNum) ? ONE : ZERO) + COMMA + ("4".equals(srcNum) ? ONE : ZERO) + COMMA
1079 + ("5".equals(srcNum) ? ONE : ZERO) + COMMA + ("6".equals(srcNum) ? ONE : ZERO));
1080 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1082 for (int i = 0; i < (favorites.length < 20 ? favorites.length : 20); i++) {
1083 connector.sendCommand(SRC_KEY + srcNum + "FAVORITESITEM" + (i + 1000) + ",0,0,\""
1084 + favPrefixMap.get(srcNum) + favorites[i] + "\"");
1085 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1090 connector.sendCommand(SRC_KEY + srcNum + "DISPINFOTWO0,0,0,0,0,0,0");
1091 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1092 connector.sendCommand(SRC_KEY + srcNum + "DISPLINES0,0,0,\"Ready\",\"\",\"\",\"\"");
1093 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1096 } catch (NuvoException | InterruptedException e) {
1097 logger.debug("Error configuring NuvoNet for source: {}", srcNum);
1104 * Schedule the reconnection job
1106 private void scheduleReconnectJob() {
1107 logger.debug("Schedule reconnect job");
1108 cancelReconnectJob();
1109 reconnectJob = scheduler.scheduleWithFixedDelay(() -> {
1110 if (!connector.isConnected()) {
1111 logger.debug("Trying to reconnect...");
1113 if (openConnection()) {
1114 logger.debug("Reconnected");
1115 // Polling status will disconnect from MPS4 on reconnect
1119 enableNuvonet(true);
1121 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Reconnection failed");
1125 }, 1, RECON_POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
1129 * If a ping is not received within ping interval the connection is closed and a reconnect job is scheduled
1131 private void schedulePingTimeoutJob() {
1133 logger.debug("Schedule Ping Timeout job");
1134 cancelPingTimeoutJob();
1135 pingJob = scheduler.schedule(() -> {
1137 scheduleReconnectJob();
1138 }, PING_TIMEOUT_SEC, TimeUnit.SECONDS);
1140 logger.debug("Ping Timeout job not valid for serial connections");
1145 * Cancel the ping timeout job
1147 private void cancelPingTimeoutJob() {
1148 ScheduledFuture<?> pingJob = this.pingJob;
1149 if (pingJob != null) {
1150 pingJob.cancel(true);
1151 this.pingJob = null;
1155 private void pollStatus() {
1156 pollStatusNeeded = false;
1157 scheduler.submit(() -> {
1158 synchronized (sequenceLock) {
1160 connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
1162 NuvoEnum.VALID_SOURCES.forEach(source -> {
1164 connector.sendQuery(NuvoEnum.valueOf(source), NuvoCommand.NAME);
1165 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1166 connector.sendQuery(NuvoEnum.valueOf(source), NuvoCommand.DISPINFO);
1167 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1168 connector.sendQuery(NuvoEnum.valueOf(source), NuvoCommand.DISPLINE);
1169 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1170 } catch (NuvoException | InterruptedException e) {
1171 logger.debug("Error Querying Source data: {}", e.getMessage());
1175 // Query all active zones to get their current status and eq configuration
1176 activeZones.forEach(zoneNum -> {
1178 connector.sendQuery(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.STATUS);
1179 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1180 connector.sendCfgCommand(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.STATUS_QUERY, BLANK);
1181 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1182 connector.sendCfgCommand(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.EQ_QUERY, BLANK);
1183 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1184 } catch (NuvoException | InterruptedException e) {
1185 logger.debug("Error Querying Zone data: {}", e.getMessage());
1189 List<StateOption> sourceStateOptions = new ArrayList<>();
1190 sourceLabels.keySet().forEach(key -> {
1191 sourceStateOptions.add(new StateOption(key, sourceLabels.get(key)));
1194 // Put the source labels on all active zones
1195 activeZones.forEach(zoneNum -> {
1196 stateDescriptionProvider.setStateOptions(
1197 new ChannelUID(getThing().getUID(),
1198 ZONE.toLowerCase() + zoneNum + CHANNEL_DELIMIT + CHANNEL_TYPE_SOURCE),
1199 sourceStateOptions);
1201 } catch (NuvoException e) {
1202 logger.debug("Error polling status from Nuvo: {}", e.getMessage());
1209 * Cancel the reconnection job
1211 private void cancelReconnectJob() {
1212 ScheduledFuture<?> reconnectJob = this.reconnectJob;
1213 if (reconnectJob != null) {
1214 reconnectJob.cancel(true);
1215 this.reconnectJob = null;
1220 * Schedule the polling job
1222 private void schedulePollingJob() {
1226 logger.debug("MPS4 doesn't support polling");
1229 logger.debug("Schedule polling job");
1232 // when the Nuvo amp is off, this will keep the connection (esp Serial over IP) alive and detect if the
1233 // connection goes down
1234 pollingJob = scheduler.scheduleWithFixedDelay(() -> {
1235 if (connector.isConnected()) {
1236 logger.debug("Polling the component for updated status...");
1238 synchronized (sequenceLock) {
1240 connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
1241 } catch (NuvoException e) {
1242 logger.debug("Polling error: {}", e.getMessage());
1245 // if the last event received was more than 1.25 intervals ago,
1246 // the component is not responding even though the connection is still good
1247 if ((System.currentTimeMillis() - lastEventReceived) > (POLLING_INTERVAL_SEC * 1.25 * 1000)) {
1248 logger.debug("Component not responding to status requests");
1249 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1250 "Component not responding to status requests");
1252 scheduleReconnectJob();
1256 }, INITIAL_POLLING_DELAY_SEC, POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
1260 * Cancel the polling job
1262 private void cancelPollingJob() {
1263 ScheduledFuture<?> pollingJob = this.pollingJob;
1264 if (pollingJob != null) {
1265 pollingJob.cancel(true);
1266 this.pollingJob = null;
1271 * Schedule the clock sync job
1273 private void scheduleClockSyncJob() {
1274 logger.debug("Schedule clock sync job");
1275 cancelClockSyncJob();
1276 clockSyncJob = scheduler.scheduleWithFixedDelay(() -> {
1277 if (this.isGConcerto) {
1279 SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy,MM,dd,HH,mm");
1280 connector.sendCommand(NuvoCommand.CFGTIME.getValue() + dateFormat.format(new Date()));
1281 } catch (NuvoException e) {
1282 logger.debug("Error syncing clock: {}", e.getMessage());
1285 this.cancelClockSyncJob();
1287 }, INITIAL_CLOCK_SYNC_DELAY_SEC, CLOCK_SYNC_INTERVAL_SEC, TimeUnit.SECONDS);
1291 * Cancel the clock sync job
1293 private void cancelClockSyncJob() {
1294 ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
1295 if (clockSyncJob != null) {
1296 clockSyncJob.cancel(true);
1297 this.clockSyncJob = null;
1302 * Update the state of a channel (original method signature)
1304 * @param target the channel group
1305 * @param channelType the channel group item
1306 * @param value the value to be updated
1308 private void updateChannelState(NuvoEnum target, String channelType, String value) {
1309 updateChannelState(target, channelType, value, NO_ART);
1313 * Update the state of a channel (overloaded method to handle album_art channel)
1315 * @param target the channel group
1316 * @param channelType the channel group item
1317 * @param value the value to be updated
1318 * @param bytes the byte[] to load into the Image channel
1320 private void updateChannelState(NuvoEnum target, String channelType, String value, byte[] bytes) {
1321 String channel = target.name().toLowerCase() + CHANNEL_DELIMIT + channelType;
1323 if (!isLinked(channel)) {
1327 State state = UnDefType.UNDEF;
1329 if (UNDEF.equals(value)) {
1330 updateState(channel, state);
1334 switch (channelType) {
1335 case CHANNEL_TYPE_POWER:
1336 case CHANNEL_TYPE_MUTE:
1337 case CHANNEL_TYPE_DND:
1338 case CHANNEL_TYPE_PARTY:
1339 case CHANNEL_TYPE_ALLMUTE:
1340 case CHANNEL_TYPE_PAGE:
1341 case CHANNEL_TYPE_LOUDNESS:
1342 state = ON.equals(value) ? OnOffType.ON : OnOffType.OFF;
1344 case CHANNEL_TYPE_LOCK:
1345 state = ON.equals(value) ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
1347 case CHANNEL_TYPE_SOURCE:
1348 case CHANNEL_TYPE_TREBLE:
1349 case CHANNEL_TYPE_BASS:
1350 case CHANNEL_TYPE_BALANCE:
1351 state = new DecimalType(value);
1353 case CHANNEL_TYPE_VOLUME:
1354 int volume = Integer.parseInt(value);
1355 long volumePct = Math
1356 .round((double) (MAX_VOLUME - volume) / (double) (MAX_VOLUME - MIN_VOLUME) * 100.0);
1357 state = new PercentType(BigDecimal.valueOf(volumePct));
1359 case CHANNEL_TYPE_BUTTONPRESS:
1360 case CHANNEL_DISPLAY_LINE1:
1361 case CHANNEL_DISPLAY_LINE2:
1362 case CHANNEL_DISPLAY_LINE3:
1363 case CHANNEL_DISPLAY_LINE4:
1364 case CHANNEL_BUTTON_PRESS:
1365 state = new StringType(value);
1367 case CHANNEL_PLAY_MODE:
1368 state = new StringType(NuvoStatusCodes.PLAY_MODE.get(value));
1370 case CHANNEL_TRACK_LENGTH:
1371 case CHANNEL_TRACK_POSITION:
1372 state = new QuantityType<Time>(Integer.parseInt(value) / 10, NuvoHandler.API_SECOND_UNIT);
1374 case CHANNEL_ALBUM_ART:
1375 state = new RawType(bytes, RawType.DEFAULT_MIME_TYPE);
1380 updateState(channel, state);
1384 * For grouped zones, update the source channel for all group members
1386 * @param zoneEnum the zone where the source was changed
1387 * @param srcId the new source number that was selected
1389 private void updateSrcForZoneGroup(NuvoEnum zoneEnum, String srcId) {
1390 // check if this zone is in a group, if so update the other group member's selected source
1391 nuvoGroupMap.forEach((groupId, groupZones) -> {
1392 if (groupZones.contains(zoneEnum)) {
1393 groupZones.forEach(z -> {
1394 if (!zoneEnum.equals(z)) {
1395 updateChannelState(z, CHANNEL_TYPE_SOURCE, srcId);
1403 * Handle a button press from a UI Player item
1405 * @param target the nuvo zone to receive the command
1406 * @param command the button press command to send to the zone
1408 private void handleControlCommand(NuvoEnum target, Command command) throws NuvoException {
1409 if (command instanceof PlayPauseType) {
1410 connector.sendCommand(target, NuvoCommand.PLAYPAUSE);
1411 } else if (command instanceof NextPreviousType) {
1412 if (command == NextPreviousType.NEXT) {
1413 connector.sendCommand(target, NuvoCommand.NEXT);
1414 } else if (command == NextPreviousType.PREVIOUS) {
1415 connector.sendCommand(target, NuvoCommand.PREV);
1418 logger.warn("Unknown control command: {}", command);