2 * Copyright (c) 2010-2024 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.Date;
26 import java.util.HashMap;
27 import java.util.HashSet;
28 import java.util.List;
30 import java.util.TreeMap;
31 import java.util.UUID;
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.NuvoEnum;
59 import org.openhab.binding.nuvo.internal.communication.NuvoImageResizer;
60 import org.openhab.binding.nuvo.internal.communication.NuvoIpConnector;
61 import org.openhab.binding.nuvo.internal.communication.NuvoMessageEvent;
62 import org.openhab.binding.nuvo.internal.communication.NuvoMessageEventListener;
63 import org.openhab.binding.nuvo.internal.communication.NuvoSerialConnector;
64 import org.openhab.binding.nuvo.internal.communication.NuvoStatusCodes;
65 import org.openhab.binding.nuvo.internal.configuration.NuvoThingConfiguration;
66 import org.openhab.binding.nuvo.internal.dto.JAXBUtils;
67 import org.openhab.binding.nuvo.internal.dto.NuvoMenu;
68 import org.openhab.binding.nuvo.internal.dto.NuvoMenu.Source.TopMenu;
69 import org.openhab.core.io.transport.serial.SerialPortManager;
70 import org.openhab.core.library.types.DecimalType;
71 import org.openhab.core.library.types.NextPreviousType;
72 import org.openhab.core.library.types.OnOffType;
73 import org.openhab.core.library.types.OpenClosedType;
74 import org.openhab.core.library.types.PercentType;
75 import org.openhab.core.library.types.PlayPauseType;
76 import org.openhab.core.library.types.QuantityType;
77 import org.openhab.core.library.types.RawType;
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_EQ_PATTERN = Pattern.compile("^BASS(.*),TREB(.*),BAL(.*),LOUDCMP([0-1])$");
137 private static final Pattern ZONE_CFG_PATTERN = Pattern.compile(
138 "^ENABLE1,NAME\"(.*)\",SLAVETO(.*),GROUP([0-4]),SOURCES(.*),XSRC(.*),IR(.*),DND(.*),LOCKED(.*),SLAVEEQ(.*)$");
139 private static final Pattern MCS_INSTANCE_PATTERN = Pattern.compile("MCSInstance\",\"value\":\"(.*?)\"");
140 private static final Pattern ART_GUID_PATTERN = Pattern.compile("NowPlayingGuid\",\"value\":\"\\{(.*?)\\}\"\\}");
142 private final Logger logger = LoggerFactory.getLogger(NuvoHandler.class);
143 private final NuvoStateDescriptionOptionProvider stateDescriptionProvider;
144 private final SerialPortManager serialPortManager;
145 private final HttpClient httpClient;
147 private @Nullable ScheduledFuture<?> reconnectJob;
148 private @Nullable ScheduledFuture<?> pollingJob;
149 private @Nullable ScheduledFuture<?> clockSyncJob;
150 private @Nullable ScheduledFuture<?> pingJob;
152 private NuvoConnector connector = new NuvoIpConnector();
153 private long lastEventReceived = System.currentTimeMillis();
154 private int numZones = 1;
155 private String versionString = BLANK;
156 private boolean isGConcerto = false;
157 private Object sequenceLock = new Object();
159 private boolean isAnyOhNuvoNet = false;
160 private NuvoMenu nuvoMenus = new NuvoMenu();
161 private HashMap<String, Set<NuvoEnum>> nuvoGroupMap = new HashMap<>();
162 private HashMap<NuvoEnum, Integer> nuvoNetSrcMap = new HashMap<>();
163 private HashMap<NuvoEnum, String> favPrefixMap = new HashMap<>();
164 private HashMap<NuvoEnum, String[]> favoriteMap = new HashMap<>();
165 private HashMap<NuvoEnum, NuvoEnum> sourceZoneMap = new HashMap<>();
166 private HashMap<NuvoEnum, String> sourceInstanceMap = new HashMap<>();
168 private HashMap<NuvoEnum, byte[]> albumArtMap = new HashMap<>();
169 private HashMap<NuvoEnum, Integer> albumArtIds = new HashMap<>();
170 private HashMap<NuvoEnum, String> dispInfoCache = new HashMap<>();
171 private HashMap<NuvoEnum, String> mps4ArtGuids = new HashMap<>();
173 Set<Integer> activeZones = new HashSet<>(1);
175 // A tree map that maps the source ids to source labels
176 TreeMap<String, String> sourceLabels = new TreeMap<>();
178 // Indicates if there is a need to poll status because of a disconnection used for MPS4 systems
179 boolean pollStatusNeeded = true;
180 boolean isMps4 = false;
181 String mps4Host = BLANK;
186 public NuvoHandler(Thing thing, NuvoStateDescriptionOptionProvider stateDescriptionProvider,
187 SerialPortManager serialPortManager, HttpClient httpClient) {
189 this.stateDescriptionProvider = stateDescriptionProvider;
190 this.serialPortManager = serialPortManager;
191 this.httpClient = httpClient;
195 public void initialize() {
196 final String uid = this.getThing().getUID().getAsString();
197 NuvoThingConfiguration config = getConfigAs(NuvoThingConfiguration.class);
198 final String serialPort = config.serialPort;
199 final String host = config.host;
200 final Integer port = config.port;
201 final Integer numZones = config.numZones;
203 // Check configuration settings
204 String configError = null;
205 if ((serialPort == null || serialPort.isEmpty()) && (host == null || host.isEmpty())) {
206 configError = "undefined serialPort and host configuration settings; please set one of them";
207 } else if (serialPort != null && (host == null || host.isEmpty())) {
208 if (serialPort.toLowerCase().startsWith("rfc2217")) {
209 configError = "use host and port configuration settings for a serial over IP connection";
213 configError = "undefined port configuration setting";
214 } else if (port <= 0) {
215 configError = "invalid port configuration setting";
219 if (configError != null) {
220 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configError);
224 if (serialPort != null && !serialPort.isEmpty()) {
225 connector = new NuvoSerialConnector(serialPortManager, serialPort, uid);
226 } else if (host != null && port != null) {
227 connector = new NuvoIpConnector(host, port, uid);
228 this.isMps4 = (port.intValue() == MPS4_PORT);
231 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
232 "Either Serial port or Host & Port must be specifed");
236 nuvoNetSrcMap.put(NuvoEnum.SOURCE1, config.nuvoNetSrc1);
237 nuvoNetSrcMap.put(NuvoEnum.SOURCE2, config.nuvoNetSrc2);
238 nuvoNetSrcMap.put(NuvoEnum.SOURCE3, config.nuvoNetSrc3);
239 nuvoNetSrcMap.put(NuvoEnum.SOURCE4, config.nuvoNetSrc4);
240 nuvoNetSrcMap.put(NuvoEnum.SOURCE5, config.nuvoNetSrc5);
241 nuvoNetSrcMap.put(NuvoEnum.SOURCE6, config.nuvoNetSrc6);
243 IntStream.range(1, 5).forEach(i -> nuvoGroupMap.put(String.valueOf(i), new HashSet<>()));
246 logger.debug("Port set to {} configuring binding for MPS4 compatability", MPS4_PORT);
248 this.isAnyOhNuvoNet = (config.nuvoNetSrc1.equals(2) || config.nuvoNetSrc2.equals(2)
249 || config.nuvoNetSrc3.equals(2) || config.nuvoNetSrc4.equals(2) || config.nuvoNetSrc5.equals(2)
250 || config.nuvoNetSrc6.equals(2));
252 mps4ArtGuids.put(NuvoEnum.SOURCE1, BLANK);
253 mps4ArtGuids.put(NuvoEnum.SOURCE2, BLANK);
254 mps4ArtGuids.put(NuvoEnum.SOURCE3, BLANK);
255 mps4ArtGuids.put(NuvoEnum.SOURCE4, BLANK);
256 mps4ArtGuids.put(NuvoEnum.SOURCE5, BLANK);
257 mps4ArtGuids.put(NuvoEnum.SOURCE6, BLANK);
259 if (this.isAnyOhNuvoNet) {
260 logger.debug("At least one source is configured as an openHAB NuvoNet source");
261 connector.setAnyOhNuvoNet(true);
262 loadMenuConfiguration(config);
264 favoriteMap.put(NuvoEnum.SOURCE1,
265 !config.favoritesSrc1.isEmpty() ? config.favoritesSrc1.split(COMMA) : new String[0]);
266 favoriteMap.put(NuvoEnum.SOURCE2,
267 !config.favoritesSrc2.isEmpty() ? config.favoritesSrc2.split(COMMA) : new String[0]);
268 favoriteMap.put(NuvoEnum.SOURCE3,
269 !config.favoritesSrc3.isEmpty() ? config.favoritesSrc3.split(COMMA) : new String[0]);
270 favoriteMap.put(NuvoEnum.SOURCE4,
271 !config.favoritesSrc4.isEmpty() ? config.favoritesSrc4.split(COMMA) : new String[0]);
272 favoriteMap.put(NuvoEnum.SOURCE5,
273 !config.favoritesSrc5.isEmpty() ? config.favoritesSrc5.split(COMMA) : new String[0]);
274 favoriteMap.put(NuvoEnum.SOURCE6,
275 !config.favoritesSrc6.isEmpty() ? config.favoritesSrc6.split(COMMA) : new String[0]);
277 favPrefixMap.put(NuvoEnum.SOURCE1, config.favPrefix1);
278 favPrefixMap.put(NuvoEnum.SOURCE2, config.favPrefix2);
279 favPrefixMap.put(NuvoEnum.SOURCE3, config.favPrefix3);
280 favPrefixMap.put(NuvoEnum.SOURCE4, config.favPrefix4);
281 favPrefixMap.put(NuvoEnum.SOURCE5, config.favPrefix5);
282 favPrefixMap.put(NuvoEnum.SOURCE6, config.favPrefix6);
284 albumArtIds.put(NuvoEnum.SOURCE1, 0);
285 albumArtIds.put(NuvoEnum.SOURCE2, 0);
286 albumArtIds.put(NuvoEnum.SOURCE3, 0);
287 albumArtIds.put(NuvoEnum.SOURCE4, 0);
288 albumArtIds.put(NuvoEnum.SOURCE5, 0);
289 albumArtIds.put(NuvoEnum.SOURCE6, 0);
291 albumArtMap.put(NuvoEnum.SOURCE1, NO_ART);
292 albumArtMap.put(NuvoEnum.SOURCE2, NO_ART);
293 albumArtMap.put(NuvoEnum.SOURCE3, NO_ART);
294 albumArtMap.put(NuvoEnum.SOURCE4, NO_ART);
295 albumArtMap.put(NuvoEnum.SOURCE5, NO_ART);
296 albumArtMap.put(NuvoEnum.SOURCE6, NO_ART);
300 if (numZones != null) {
301 this.numZones = numZones;
304 activeZones = IntStream.range(1, this.numZones + 1).boxed().collect(Collectors.toSet());
306 // remove the channels for the zones we are not using
307 if (this.numZones < MAX_ZONES) {
308 List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
310 List<Integer> zonesToRemove = IntStream.range(this.numZones + 1, MAX_ZONES + 1).boxed()
311 .collect(Collectors.toList());
313 zonesToRemove.forEach(zone -> channels.removeIf(c -> (c.getUID().getId().contains("zone" + zone))));
314 updateThing(editThing().withChannels(channels).build());
317 // Build a list of State options for the global favorites using user config values (if supplied)
318 String[] favoritesArr = !config.favoriteLabels.isEmpty() ? config.favoriteLabels.split(COMMA) : new String[0];
319 List<StateOption> favoriteLabelsStateOptions = new ArrayList<>();
320 IntStream.range(1, MAX_FAV + 1).forEach(i -> {
321 if (favoritesArr.length >= i) {
322 favoriteLabelsStateOptions.add(new StateOption(String.valueOf(i), favoritesArr[i - 1]));
323 } else if (favoritesArr.length == 0) {
324 favoriteLabelsStateOptions.add(new StateOption(String.valueOf(i), "Favorite " + (i)));
328 // Also add any openHAB NuvoNet source favorites to the list
329 IntStream.range(1, MAX_SRC + 1).forEach(src -> {
330 NuvoEnum source = NuvoEnum.valueOf(SOURCE + src);
331 String[] favorites = favoriteMap.get(source);
332 if (favorites != null) {
333 IntStream.range(0, favorites.length).forEach(fav -> {
334 favoriteLabelsStateOptions.add(new StateOption(String.valueOf(src * 100 + fav),
335 favPrefixMap.get(source) + favorites[fav]));
340 // Put the global favorites labels on all active zones
341 activeZones.forEach(zoneNum -> {
342 stateDescriptionProvider.setStateOptions(
343 new ChannelUID(getThing().getUID(),
344 ZONE.toLowerCase() + zoneNum + CHANNEL_DELIMIT + CHANNEL_TYPE_FAVORITE),
345 favoriteLabelsStateOptions);
348 if (config.clockSync) {
349 scheduleClockSyncJob();
352 scheduleReconnectJob();
353 schedulePollingJob();
354 schedulePingTimeoutJob();
355 updateStatus(ThingStatus.UNKNOWN);
359 public void dispose() {
360 if (this.isAnyOhNuvoNet) {
362 // disable NuvoNet for each source that was configured as an openHAB NuvoNet source
363 nuvoNetSrcMap.forEach((source, val) -> {
366 connector.sendCommand(source.getId() + "DISPINFOTWO0,0,0,0,0,0,0");
367 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
368 connector.sendCommand(
369 source.getId() + "DISPLINES0,0,0,\"Source Unavailable\",\"\",\"\",\"\"");
370 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
371 connector.sendCommand(source.getConfigId() + "NUVONET0");
372 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
373 } catch (NuvoException | InterruptedException e) {
374 logger.debug("Error sending command to disable NuvoNet source: {}", source.getNum());
379 // need '1' flag for sources configured as an MPS4 NuvoNet source, but disable openHAB NuvoNet sources
380 connector.sendCommand("SNUMBERS" + (nuvoNetSrcMap.get(NuvoEnum.SOURCE1).equals(1) ? ONE : ZERO) + COMMA
381 + (nuvoNetSrcMap.get(NuvoEnum.SOURCE2).equals(1) ? ONE : ZERO) + COMMA
382 + (nuvoNetSrcMap.get(NuvoEnum.SOURCE3).equals(1) ? ONE : ZERO) + COMMA
383 + (nuvoNetSrcMap.get(NuvoEnum.SOURCE4).equals(1) ? ONE : ZERO) + COMMA
384 + (nuvoNetSrcMap.get(NuvoEnum.SOURCE5).equals(1) ? ONE : ZERO) + COMMA
385 + (nuvoNetSrcMap.get(NuvoEnum.SOURCE6).equals(1) ? ONE : ZERO));
386 } catch (NuvoException e) {
387 logger.debug("Error sending SNUMBERS command to disable NuvoNet sources");
391 cancelReconnectJob();
393 cancelClockSyncJob();
394 cancelPingTimeoutJob();
400 public Collection<Class<? extends ThingHandlerService>> getServices() {
401 return List.of(NuvoThingActions.class);
404 public void handleRawCommand(String command) {
405 synchronized (sequenceLock) {
407 connector.sendCommand(command);
408 } catch (NuvoException e) {
409 logger.warn("Nuvo Command: {} failed", command);
415 * Handle a command from the UI
417 * @param channelUID the channel sending the command
418 * @param command the command received
422 public void handleCommand(ChannelUID channelUID, Command command) {
423 String channel = channelUID.getId();
424 String[] channelSplit = channel.split(CHANNEL_DELIMIT);
425 NuvoEnum target = NuvoEnum.valueOf(channelSplit[0].toUpperCase());
427 String channelType = channelSplit[1];
429 if (getThing().getStatus() != ThingStatus.ONLINE) {
430 logger.debug("Thing is not ONLINE; command {} from channel {} is ignored", command, channel);
434 synchronized (sequenceLock) {
435 if (!connector.isConnected()) {
436 logger.warn("Command {} from channel {} is ignored: connection not established", command, channel);
441 switch (channelType) {
442 case CHANNEL_TYPE_POWER:
443 if (command instanceof OnOffType) {
444 connector.sendCommand(target, command == OnOffType.ON ? NuvoCommand.ON : NuvoCommand.OFF);
447 case CHANNEL_TYPE_SOURCE:
448 if (command instanceof DecimalType decimalCommand) {
449 int value = decimalCommand.intValue();
450 if (value >= 1 && value <= MAX_SRC) {
451 logger.debug("Got source command {} zone {}", value, target);
452 connector.sendCommand(target, NuvoCommand.SOURCE, String.valueOf(value));
454 // update the other group member's selected source
455 updateSrcForZoneGroup(target, String.valueOf(value));
456 sourceZoneMap.put(NuvoEnum.valueOf(SOURCE + value), target);
460 case CHANNEL_TYPE_FAVORITE:
461 if (command instanceof DecimalType decimalCommand) {
462 int value = decimalCommand.intValue();
463 if (value >= 1 && value <= MAX_FAV) {
464 logger.debug("Got favorite command {} zone {}", value, target);
465 connector.sendCommand(target, NuvoCommand.FAVORITE, String.valueOf(value));
466 } else if (value >= 100 && value <= 650) {
467 String sourceNum = String.valueOf(value / 100);
468 NuvoEnum source = NuvoEnum.valueOf(SOURCE + sourceNum);
469 updateChannelState(source, CHANNEL_BUTTON_PRESS,
470 PLAY_MUSIC_PRESET + favoriteMap.get(source)[value % 100]);
471 connector.sendCommand(target, NuvoCommand.SOURCE, sourceNum);
473 // if this zone is in a group, update the other group member's selected source
474 updateSrcForZoneGroup(target, sourceNum);
478 case CHANNEL_TYPE_VOLUME:
479 if (command instanceof PercentType percentCommand) {
480 int value = (MAX_VOLUME
481 - (int) Math.round(percentCommand.doubleValue() / 100.0 * (MAX_VOLUME - MIN_VOLUME))
483 logger.debug("Got volume command {} zone {}", value, target);
484 connector.sendCommand(target, NuvoCommand.VOLUME, String.valueOf(value));
487 case CHANNEL_TYPE_MUTE:
488 if (command instanceof OnOffType) {
489 connector.sendCommand(target,
490 command == OnOffType.ON ? NuvoCommand.MUTE_ON : NuvoCommand.MUTE_OFF);
493 case CHANNEL_TYPE_TREBLE:
494 if (command instanceof DecimalType decimalCommand) {
495 int value = decimalCommand.intValue();
496 if (value >= MIN_EQ && value <= MAX_EQ) {
497 // device can only accept even values
498 if (value % 2 == 1) {
501 logger.debug("Got treble command {} zone {}", value, target);
502 connector.sendCfgCommand(target, NuvoCommand.TREBLE, String.valueOf(value));
506 case CHANNEL_TYPE_BASS:
507 if (command instanceof DecimalType decimalCommand) {
508 int value = decimalCommand.intValue();
509 if (value >= MIN_EQ && value <= MAX_EQ) {
510 if (value % 2 == 1) {
513 logger.debug("Got bass command {} zone {}", value, target);
514 connector.sendCfgCommand(target, NuvoCommand.BASS, String.valueOf(value));
518 case CHANNEL_TYPE_BALANCE:
519 if (command instanceof DecimalType decimalCommand) {
520 int value = decimalCommand.intValue();
521 if (value >= MIN_EQ && value <= MAX_EQ) {
522 if (value % 2 == 1) {
525 logger.debug("Got balance command {} zone {}", value, target);
526 connector.sendCfgCommand(target, NuvoCommand.BALANCE,
527 NuvoStatusCodes.getBalanceFromInt(value));
531 case CHANNEL_TYPE_LOUDNESS:
532 if (command instanceof OnOffType) {
533 connector.sendCfgCommand(target, NuvoCommand.LOUDNESS,
534 command == OnOffType.ON ? ONE : ZERO);
537 case CHANNEL_TYPE_CONTROL:
538 handleControlCommand(target, command);
540 case CHANNEL_TYPE_DND:
541 if (command instanceof OnOffType) {
542 connector.sendCommand(target,
543 command == OnOffType.ON ? NuvoCommand.DND_ON : NuvoCommand.DND_OFF);
546 case CHANNEL_TYPE_PARTY:
547 if (command instanceof OnOffType) {
548 connector.sendCommand(target,
549 command == OnOffType.ON ? NuvoCommand.PARTY_ON : NuvoCommand.PARTY_OFF);
552 case CHANNEL_DISPLAY_LINE1:
553 if (command instanceof StringType) {
554 connector.sendCommand(target, NuvoCommand.DISPLINE1, "\"" + command + "\"");
557 case CHANNEL_DISPLAY_LINE2:
558 if (command instanceof StringType) {
559 connector.sendCommand(target, NuvoCommand.DISPLINE2, "\"" + command + "\"");
562 case CHANNEL_DISPLAY_LINE3:
563 if (command instanceof StringType) {
564 connector.sendCommand(target, NuvoCommand.DISPLINE3, "\"" + command + "\"");
567 case CHANNEL_DISPLAY_LINE4:
568 if (command instanceof StringType) {
569 connector.sendCommand(target, NuvoCommand.DISPLINE4, "\"" + command + "\"");
572 case CHANNEL_TYPE_ALLOFF:
573 if (command instanceof OnOffType) {
574 connector.sendCommand(NuvoCommand.ALLOFF);
577 case CHANNEL_TYPE_ALLMUTE:
578 if (command instanceof OnOffType) {
579 connector.sendCommand(
580 command == OnOffType.ON ? NuvoCommand.ALLMUTE_ON : NuvoCommand.ALLMUTE_OFF);
583 case CHANNEL_TYPE_PAGE:
584 if (command instanceof OnOffType) {
585 connector.sendCommand(command == OnOffType.ON ? NuvoCommand.PAGE_ON : NuvoCommand.PAGE_OFF);
588 case CHANNEL_TYPE_SENDCMD:
589 if (command instanceof StringType) {
590 String commandStr = command.toString();
591 if (commandStr.contains(DISP_INFO_TWO)) {
592 NuvoEnum source = NuvoEnum
593 .valueOf(commandStr.split(DISP_INFO_TWO)[0].replace("S", SOURCE));
594 dispInfoCache.put(source, commandStr);
596 // if 'albumartid' is present, substitute it with the albumArtId hex string
597 connector.sendCommand(commandStr.replace(ALBUM_ART_ID,
598 (OFFSET_ZERO + Integer.toHexString(albumArtIds.get(source)))));
600 connector.sendCommand(commandStr);
604 case CHANNEL_ART_URL:
605 if (command instanceof StringType) {
606 String url = command.toString();
607 if (url.startsWith(HTTP) || url.startsWith(HTTPS)) {
609 ContentResponse contentResponse = httpClient.newRequest(url).method(GET)
610 .timeout(10, TimeUnit.SECONDS).send();
611 int httpStatus = contentResponse.getStatus();
612 if (httpStatus == OK_200) {
613 albumArtMap.put(target,
614 NuvoImageResizer.resizeImage(contentResponse.getContent(), 80, 80));
616 updateChannelState(target, CHANNEL_ALBUM_ART, BLANK,
617 contentResponse.getContent());
619 albumArtMap.put(target, NO_ART);
620 albumArtIds.put(target, 0);
621 updateChannelState(target, CHANNEL_ALBUM_ART, UNDEF);
624 } catch (InterruptedException | TimeoutException | ExecutionException e) {
625 albumArtMap.put(target, NO_ART);
626 albumArtIds.put(target, 0);
627 updateChannelState(target, CHANNEL_ALBUM_ART, UNDEF);
630 albumArtIds.put(target, Math.abs(url.hashCode()));
632 // re-send the cached DISPINFOTWO message, substituting in the new albumArtId
633 if (dispInfoCache.get(target) != null) {
634 connector.sendCommand(dispInfoCache.get(target).replace(ALBUM_ART_ID,
635 (OFFSET_ZERO + Integer.toHexString(albumArtIds.get(target)))));
638 albumArtMap.put(target, NO_ART);
639 albumArtIds.put(target, 0);
640 updateChannelState(target, CHANNEL_ALBUM_ART, UNDEF);
644 case CHANNEL_SOURCE_MENU:
645 if (command instanceof StringType) {
646 updateChannelState(target, CHANNEL_BUTTON_PRESS, command.toString());
649 } catch (NuvoException e) {
650 logger.warn("Command {} from channel {} failed: {}", command, channel, e.getMessage());
651 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Sending command failed");
653 scheduleReconnectJob();
659 * Open the connection with the Nuvo device
661 * @return true if the connection is opened successfully or false if not
663 private synchronized boolean openConnection() {
664 connector.addEventListener(this);
667 } catch (NuvoException e) {
668 logger.debug("openConnection() failed: {}", e.getMessage());
670 logger.debug("openConnection(): {}", connector.isConnected() ? "connected" : "disconnected");
671 return connector.isConnected();
675 * Close the connection with the Nuvo device
677 private synchronized void closeConnection() {
678 if (connector.isConnected()) {
680 connector.removeEventListener(this);
681 pollStatusNeeded = true;
682 logger.debug("closeConnection(): disconnected");
687 * Handle an event received from the Nuvo device
689 * @param evt the event to process
692 public void onNewMessageEvent(NuvoMessageEvent evt) {
693 logger.debug("onNewMessageEvent: zone {}, source {}, value {}", evt.getZone(), evt.getSrc(), evt.getValue());
694 lastEventReceived = System.currentTimeMillis();
696 final NuvoEnum zone = !evt.getZone().isEmpty() ? NuvoEnum.valueOf(ZONE + evt.getZone()) : NuvoEnum.SYSTEM;
697 final NuvoEnum source = !evt.getSrc().isEmpty() ? NuvoEnum.valueOf(SOURCE + evt.getSrc()) : NuvoEnum.SYSTEM;
698 final String sourceZone = source.getId() + zone.getId();
699 final String updateData = evt.getValue().trim();
701 if (this.getThing().getStatus() != ThingStatus.ONLINE) {
702 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, this.versionString);
705 switch (evt.getType()) {
707 this.versionString = updateData;
708 // Determine if we are a Grand Concerto or not
709 if (this.versionString.contains(GC_STR)) {
710 logger.debug("Grand Concerto detected");
711 this.isGConcerto = true;
712 connector.setEssentia(false);
714 logger.debug("Grand Concerto not detected");
718 logger.debug("Restart message received; re-sending initialization messages");
719 enableNuvonet(false);
722 logger.debug("Ping message received- rescheduling ping timeout");
723 schedulePingTimeoutJob();
724 // Return here because receiving a ping does not indicate that one can poll
727 activeZones.forEach(zoneNum -> {
728 updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_POWER, OFF);
731 // Publish the ALLOFF event to all button channels for awareness in source rules
732 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, ZERO + COMMA + ALLOFF);
733 NuvoEnum.VALID_SOURCES.forEach(src -> {
734 updateChannelState(src, CHANNEL_BUTTON_PRESS, ALLOFF);
739 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_ALLMUTE, ONE.equals(updateData) ? ON : OFF);
740 activeZones.forEach(zoneNum -> {
741 updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_MUTE,
742 ONE.equals(updateData) ? ON : OFF);
746 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_PAGE, ONE.equals(updateData) ? ON : OFF);
748 case TYPE_SOURCE_UPDATE:
749 logger.debug("Source update: Source: {} - Value: {}", source.getNum(), updateData);
751 if (updateData.contains(DISPLINE)) {
752 // example: DISPLINE2,"Play My Song (Featuring Dee Ajayi)"
753 Matcher matcher = DISP_PATTERN.matcher(updateData);
754 if (matcher.find()) {
755 updateChannelState(source, CHANNEL_DISPLAY_LINE + matcher.group(1), matcher.group(2));
757 logger.debug("no match on message: {}", updateData);
759 } else if (updateData.contains(DISPINFO)) {
760 // example: DISPINFO,DUR0,POS70,STATUS2 (DUR and POS are expressed in tenths of a second)
761 // 6 places(tenths of a second)-> max 999,999 /10/60/60/24 = 1.15 days
762 Matcher matcher = DISP_INFO_PATTERN.matcher(updateData);
763 if (matcher.find()) {
764 updateChannelState(source, CHANNEL_TRACK_LENGTH, matcher.group(1));
765 updateChannelState(source, CHANNEL_TRACK_POSITION, matcher.group(2));
766 updateChannelState(source, CHANNEL_PLAY_MODE, matcher.group(3));
768 // if this is an MPS4 source, the following retrieves album art when the source is playing
769 if (nuvoNetSrcMap.get(source) == 1
770 && isLinked(source.name().toLowerCase() + CHANNEL_DELIMIT + CHANNEL_ALBUM_ART)) {
771 if (MPS4_PLAYING_MODES.contains(matcher.group(3))) {
772 logger.debug("DISPINFO update, trying to get album art");
773 getMps4AlbumArt(source);
774 } else if (MPS4_IDLE_MODES.contains(matcher.group(3)) && ZERO.equals(matcher.group(1))) {
775 // clear album art channel for this source
776 logger.debug("DISPINFO update- not playing; clearing art, mode: {}", matcher.group(3));
777 updateChannelState(source, CHANNEL_ALBUM_ART, UNDEF);
778 mps4ArtGuids.put(source, BLANK);
782 logger.debug("no match on message: {}", updateData);
784 } else if (updateData.contains(NAME_QUOTE)) {
785 // example: NAME"Ipod"
786 String name = updateData.split("\"")[1];
787 sourceLabels.put(String.valueOf(source.getNum()), name);
790 case TYPE_ZONE_UPDATE:
791 logger.debug("Zone update: Zone: {} - Value: {}", zone.getNum(), updateData);
793 // or: ON,SRC3,VOL63,DND0,LOCK0
794 // or: ON,SRC3,MUTE,DND0,LOCK0
796 if (OFF.equals(updateData)) {
797 updateChannelState(zone, CHANNEL_TYPE_POWER, OFF);
798 updateChannelState(zone, CHANNEL_TYPE_SOURCE, UNDEF);
800 Matcher matcher = ZONE_PATTERN.matcher(updateData);
801 if (matcher.find()) {
802 updateChannelState(zone, CHANNEL_TYPE_POWER, ON);
803 updateChannelState(zone, CHANNEL_TYPE_SOURCE, matcher.group(1));
804 sourceZoneMap.put(NuvoEnum.valueOf(SOURCE + matcher.group(1)), zone);
806 // update the other group member's selected source
807 updateSrcForZoneGroup(zone, matcher.group(1));
809 if (MUTE.equals(matcher.group(2))) {
810 updateChannelState(zone, CHANNEL_TYPE_MUTE, ON);
812 updateChannelState(zone, CHANNEL_TYPE_MUTE, NuvoCommand.OFF.getValue());
813 updateChannelState(zone, CHANNEL_TYPE_VOLUME, matcher.group(2).replace(VOL, BLANK));
816 updateChannelState(zone, CHANNEL_TYPE_DND, ONE.equals(matcher.group(3)) ? ON : OFF);
817 updateChannelState(zone, CHANNEL_TYPE_LOCK, ONE.equals(matcher.group(4)) ? ON : OFF);
819 logger.debug("no match on message: {}", updateData);
823 case TYPE_ZONE_SOURCE_BUTTON:
824 logger.debug("Source Button pressed: Source: {} - Button: {}", source.getNum(), updateData);
825 updateChannelState(source, CHANNEL_BUTTON_PRESS, updateData);
826 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, zone.getNum() + COMMA + updateData);
829 String buttonAction = NuvoStatusCodes.BUTTON_CODE.get(updateData);
831 if (buttonAction != null) {
832 logger.debug("NuvoNet Source Button pressed: Source: {} - Button: {}", source.getNum(),
834 updateChannelState(source, CHANNEL_BUTTON_PRESS, buttonAction);
835 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, zone.getNum() + COMMA + buttonAction);
837 logger.debug("NuvoNet Source Button pressed: Source: {} - Unknown button code: {}", source.getNum(),
839 updateChannelState(source, CHANNEL_BUTTON_PRESS, updateData);
840 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, zone.getNum() + COMMA + updateData);
843 case TYPE_NN_MENU_ITEM_SELECTED:
844 // ignore this update unless openHAB is handling this source
845 if (nuvoNetSrcMap.get(source).equals(2)) {
846 String[] updateDataSplit = updateData.split(COMMA);
847 String menuId = updateDataSplit[0];
848 int menuItemIdx = Integer.parseInt(updateDataSplit[1]) - 1;
850 boolean exitMenu = false;
851 if ("0xFFFFFFFF".equals(menuId)) {
852 TopMenu topMenuItem = nuvoMenus.getSource().get(source.getNum() - 1).getTopMenu()
854 logger.debug("Top Menu item selected: Source: {} - Menu Item: {}", source.getNum(),
855 topMenuItem.getText());
856 updateChannelState(source, CHANNEL_BUTTON_PRESS, topMenuItem.getText());
857 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS,
858 zone.getNum() + COMMA + topMenuItem.getText());
860 List<String> subMenuItems = topMenuItem.getItems();
862 if (subMenuItems.isEmpty()) {
865 // send submenu (maximum of 20 items)
866 int subMenuSize = subMenuItems.size() < 20 ? subMenuItems.size() : 20;
868 connector.sendCommand(sourceZone + "MENU" + (menuItemIdx + 11) + ",0,0," + subMenuSize
869 + ",0,0," + subMenuSize + ",\"" + topMenuItem.getText() + "\"");
870 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
872 for (int i = 0; i < subMenuSize; i++) {
873 connector.sendCommand(
874 sourceZone + "MENUITEM" + (i + 1) + ",0,0,\"" + subMenuItems.get(i) + "\"");
876 } catch (NuvoException | InterruptedException e) {
877 logger.debug("Error sending sub menu to {}", sourceZone);
881 // a sub menu item was selected
882 TopMenu topMenuItem = nuvoMenus.getSource().get(source.getNum() - 1).getTopMenu()
883 .get(Integer.decode(menuId) - 11);
884 String subMenuItem = topMenuItem.getItems().get(menuItemIdx);
886 logger.debug("Sub Menu item selected: Source: {} - Menu Item: {}", source.getNum(),
887 topMenuItem.getText() + "|" + subMenuItem);
888 updateChannelState(source, CHANNEL_BUTTON_PRESS, topMenuItem.getText() + "|" + subMenuItem);
889 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS,
890 zone.getNum() + COMMA + topMenuItem.getText() + "|" + subMenuItem);
896 // tell the zone to exit the menu
897 connector.sendCommand(sourceZone + "MENU0,0,0,0,0,0,0,\"\"");
898 } catch (NuvoException e) {
899 logger.debug("Error sending exit menu command to {}", sourceZone);
904 case TYPE_NN_MENUREQ:
905 // ignore this update unless openHAB is handling this source
906 if (nuvoNetSrcMap.get(source).equals(2)) {
907 logger.debug("Menu Request: Source: {} - Value: {}", source.getNum(), updateData);
908 // For now we only support one level deep menus. If second field is '1', indicates go back to main
910 String[] menuDataSplit = updateData.split(COMMA);
911 if (menuDataSplit.length > 2 && ONE.equals(menuDataSplit[1])) {
913 connector.sendCommand(sourceZone + "MENU0xFFFFFFFF,0,0,0,0,0,0,\"\"");
914 } catch (NuvoException e) {
915 logger.debug("Error sending main menu command to {}", sourceZone);
920 case TYPE_ZONE_CONFIG:
921 logger.debug("Zone Configuration: Zone: {} - Value: {}", zone.getNum(), updateData);
922 // example: BASS1,TREB-2,BALR2,LOUDCMP1
923 Matcher matcher = ZONE_CFG_EQ_PATTERN.matcher(updateData);
924 if (matcher.find()) {
925 updateChannelState(zone, CHANNEL_TYPE_BASS, matcher.group(1));
926 updateChannelState(zone, CHANNEL_TYPE_TREBLE, matcher.group(2));
927 updateChannelState(zone, CHANNEL_TYPE_BALANCE, NuvoStatusCodes.getBalanceFromStr(matcher.group(3)));
928 updateChannelState(zone, CHANNEL_TYPE_LOUDNESS, ONE.equals(matcher.group(4)) ? ON : OFF);
930 matcher = ZONE_CFG_PATTERN.matcher(updateData);
931 // example: ENABLE1,NAME"Great Room",SLAVETO0,GROUP1,SOURCES63,XSRC0,IR1,DND0,LOCKED0,SLAVEEQ0
932 if (matcher.find()) {
933 // TODO: utilize other info such as zone name, available sources bitmask, etc.
935 // if this zone is a member of a group (1-4), add the zone's enum to the appropriate group map
936 if (!ZERO.equals(matcher.group(3))) {
937 nuvoGroupMap.get(matcher.group(3)).add(zone);
940 logger.debug("no match on message: {}", updateData);
944 case TYPE_NN_ALBUM_ART_REQ:
945 // ignore this update unless openHAB is handling this source
946 if (nuvoNetSrcMap.get(source).equals(2)) {
947 logger.debug("Album Art Request for Source: {} - Data: {}", source.getNum(), updateData);
948 // 0x620FD879,80,80,2,0x00C0C0C0,0,0,0,0,1
949 String[] albumArtReq = updateData.split(COMMA);
950 albumArtIds.put(source, Integer.decode(albumArtReq[0]));
953 if (albumArtMap.get(source).length > 1) {
954 connector.sendCommand(source.getId() + ALBUM_ART_AVAILABLE + albumArtIds.get(source) + COMMA
955 + albumArtMap.get(source).length);
957 connector.sendCommand(source.getId() + ALBUM_ART_AVAILABLE + ZERO_COMMA);
959 } catch (NuvoException e) {
960 logger.debug("Error sending ALBUMARTAVAILABLE command for source: {}", source.getNum());
964 case TYPE_NN_ALBUM_ART_FRAG_REQ:
965 // ignore this update unless openHAB is handling this source
966 if (nuvoNetSrcMap.get(source).equals(2)) {
967 logger.debug("Album Art Fragment Request for Source: {} - Data: {}", source.getNum(), updateData);
968 // 0x620FD879,0,750 (id, requested offset from start of image, byte length requested)
969 String[] albumArtFragReq = updateData.split(COMMA);
970 int requestedId = Integer.decode(albumArtFragReq[0]);
971 int offset = Integer.parseInt(albumArtFragReq[1]);
972 int length = Integer.parseInt(albumArtFragReq[2]);
974 if (requestedId == albumArtIds.get(source)) {
975 byte[] chunk = new byte[length];
976 byte[] albumArtBytes = albumArtMap.get(source);
978 if (albumArtBytes != null) {
979 System.arraycopy(albumArtBytes, offset, chunk, 0, length);
980 final String frag = Base64.getEncoder().encodeToString(chunk);
982 connector.sendCommand(source.getId() + ALBUM_ART_FRAG + requestedId + COMMA + offset
983 + COMMA + frag.length() + COMMA + frag);
984 } catch (NuvoException e) {
985 logger.debug("Error sending ALBUMARTFRAG command for source: {}, artId: {}",
986 source.getNum(), requestedId);
992 case TYPE_NN_FAVORITE_REQ:
993 // ignore this update unless openHAB is handling this source
994 if (nuvoNetSrcMap.get(source).equals(2)) {
995 logger.debug("Favorite request for source: {} - favoriteId: {}", source.getNum(), updateData);
997 int playlistIdx = Integer.parseInt(updateData, 16) - 1000;
998 updateChannelState(source, CHANNEL_BUTTON_PRESS,
999 PLAY_MUSIC_PRESET + favoriteMap.get(source)[playlistIdx]);
1000 } catch (NumberFormatException nfe) {
1001 logger.debug("Unable to parse favoriteId: {}", updateData);
1006 logger.debug("onNewMessageEvent: unhandled event type {}", evt.getType());
1007 // Return here because receiving an unknown message does not indicate that one can poll
1011 if (isMps4 && pollStatusNeeded) {
1016 private void loadMenuConfiguration(NuvoThingConfiguration config) {
1017 StringBuilder menuXml = new StringBuilder("<menu>");
1019 if (!config.menuXmlSrc1.isEmpty()) {
1020 menuXml.append("<source>" + config.menuXmlSrc1 + "</source>");
1022 menuXml.append("<source/>");
1024 if (!config.menuXmlSrc2.isEmpty()) {
1025 menuXml.append("<source>" + config.menuXmlSrc2 + "</source>");
1027 menuXml.append("<source/>");
1029 if (!config.menuXmlSrc3.isEmpty()) {
1030 menuXml.append("<source>" + config.menuXmlSrc3 + "</source>");
1032 menuXml.append("<source/>");
1034 if (!config.menuXmlSrc4.isEmpty()) {
1035 menuXml.append("<source>" + config.menuXmlSrc4 + "</source>");
1037 menuXml.append("<source/>");
1039 if (!config.menuXmlSrc5.isEmpty()) {
1040 menuXml.append("<source>" + config.menuXmlSrc5 + "</source>");
1042 menuXml.append("<source/>");
1044 if (!config.menuXmlSrc6.isEmpty()) {
1045 menuXml.append("<source>" + config.menuXmlSrc6 + "</source>");
1047 menuXml.append("<source/>");
1049 menuXml.append("</menu>");
1052 JAXBContext ctx = JAXBUtils.JAXBCONTEXT_NUVO_MENU;
1054 Unmarshaller unmarshaller = ctx.createUnmarshaller();
1055 if (unmarshaller != null) {
1056 XMLStreamReader xsr = JAXBUtils.XMLINPUTFACTORY
1057 .createXMLStreamReader(new StringReader(menuXml.toString()));
1058 NuvoMenu menu = (NuvoMenu) unmarshaller.unmarshal(xsr);
1065 logger.debug("No JAXBContext available to parse Nuvo Menu XML");
1066 } catch (JAXBException | XMLStreamException e) {
1067 logger.warn("Error processing Nuvo Menu XML: {}", e.getLocalizedMessage());
1071 private void enableNuvonet(boolean showReady) {
1072 if (!this.isAnyOhNuvoNet) {
1076 // enable NuvoNet for each source configured as an openHAB NuvoNet source
1077 nuvoNetSrcMap.forEach((source, val) -> {
1078 if (val.equals(2)) {
1080 connector.sendCommand(source.getConfigId() + "NUVONET1");
1081 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1082 } catch (NuvoException | InterruptedException e) {
1083 logger.debug("Error sending SCFG command for source: {}", source.getNum());
1089 // set '1' flag for each source configured as an MPS4 NuvoNet source or openHAB NuvoNet source
1090 connector.sendCommand("SNUMBERS" + nuvoNetSrcMap.get(NuvoEnum.SOURCE1).compareTo(0) + COMMA
1091 + nuvoNetSrcMap.get(NuvoEnum.SOURCE2).compareTo(0) + COMMA
1092 + nuvoNetSrcMap.get(NuvoEnum.SOURCE3).compareTo(0) + COMMA
1093 + nuvoNetSrcMap.get(NuvoEnum.SOURCE4).compareTo(0) + COMMA
1094 + nuvoNetSrcMap.get(NuvoEnum.SOURCE5).compareTo(0) + COMMA
1095 + nuvoNetSrcMap.get(NuvoEnum.SOURCE6).compareTo(0));
1096 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1097 } catch (NuvoException | InterruptedException e) {
1098 logger.debug("Error sending SNUMBERS command");
1101 // go though each source and if is openHAB NuvoNet then configure menu, favorites, etc.
1102 nuvoNetSrcMap.forEach((source, val) -> {
1103 if (val.equals(2)) {
1105 List<TopMenu> topMenuItems = nuvoMenus.getSource().get(source.getNum() - 1).getTopMenu();
1107 if (!topMenuItems.isEmpty()) {
1108 connector.sendCommand(
1109 source.getId() + "MENU," + (topMenuItems.size() < 10 ? topMenuItems.size() : 10));
1110 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1112 for (int i = 0; i < (topMenuItems.size() < 10 ? topMenuItems.size() : 10); i++) {
1113 connector.sendCommand(source.getId() + "MENUITEM" + (i + 1) + ","
1114 + (topMenuItems.get(i).getItems().isEmpty() ? ZERO : ONE) + ",0,\""
1115 + topMenuItems.get(i).getText() + "\"");
1116 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1119 // Build a State options selection that represents this source's custom menu
1120 List<StateOption> sourceMenuStateOptions = new ArrayList<>();
1121 topMenuItems.forEach(topItem -> {
1122 sourceMenuStateOptions.add(new StateOption(topItem.getText(), topItem.getText()));
1123 topItem.getItems().forEach(subItem -> sourceMenuStateOptions
1124 .add(new StateOption(topItem.getText() + "|" + subItem, "-> " + subItem)));
1126 stateDescriptionProvider.setStateOptions(
1127 new ChannelUID(getThing().getUID(),
1128 source.name().toLowerCase() + CHANNEL_DELIMIT + CHANNEL_SOURCE_MENU),
1129 sourceMenuStateOptions);
1132 String[] favorites = favoriteMap.get(source);
1133 if (favorites != null) {
1134 connector.sendCommand(source.getId() + "FAVORITES"
1135 + (favorites.length < 20 ? favorites.length : 20) + COMMA
1136 + (source.getNum() == 1 ? ONE : ZERO) + COMMA + (source.getNum() == 2 ? ONE : ZERO)
1137 + COMMA + (source.getNum() == 3 ? ONE : ZERO) + COMMA
1138 + (source.getNum() == 4 ? ONE : ZERO) + COMMA + (source.getNum() == 5 ? ONE : ZERO)
1139 + COMMA + (source.getNum() == 6 ? ONE : ZERO));
1140 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1142 for (int i = 0; i < (favorites.length < 20 ? favorites.length : 20); i++) {
1143 connector.sendCommand(source.getId() + "FAVORITESITEM" + (i + 1000) + ",0,0,\""
1144 + favPrefixMap.get(source) + favorites[i] + "\"");
1145 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1150 connector.sendCommand(source.getId() + "DISPINFOTWO0,0,0,0,0,0,0");
1151 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1152 connector.sendCommand(source.getId() + "DISPLINES0,0,0,\"Ready\",\"\",\"\",\"\"");
1153 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1156 } catch (NuvoException | InterruptedException e) {
1157 logger.debug("Error configuring NuvoNet for source: {}", source.getNum());
1164 * Schedule the reconnection job
1166 private void scheduleReconnectJob() {
1167 logger.debug("Schedule reconnect job");
1168 cancelReconnectJob();
1169 reconnectJob = scheduler.scheduleWithFixedDelay(() -> {
1170 if (!connector.isConnected()) {
1171 logger.debug("Trying to reconnect...");
1173 if (openConnection()) {
1174 logger.debug("Reconnected");
1175 // Polling status will disconnect from MPS4 on reconnect
1179 enableNuvonet(true);
1181 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Reconnection failed");
1185 }, 1, RECON_POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
1189 * If a ping is not received within ping interval the connection is closed and a reconnect job is scheduled
1191 private void schedulePingTimeoutJob() {
1193 logger.debug("Schedule Ping Timeout job");
1194 cancelPingTimeoutJob();
1195 pingJob = scheduler.schedule(() -> {
1197 scheduleReconnectJob();
1198 }, PING_TIMEOUT_SEC, TimeUnit.SECONDS);
1200 logger.debug("Ping Timeout job not valid for serial connections");
1205 * Cancel the ping timeout job
1207 private void cancelPingTimeoutJob() {
1208 ScheduledFuture<?> pingJob = this.pingJob;
1209 if (pingJob != null) {
1210 pingJob.cancel(true);
1211 this.pingJob = null;
1215 private void pollStatus() {
1216 pollStatusNeeded = false;
1217 scheduler.submit(() -> {
1218 synchronized (sequenceLock) {
1220 connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
1222 NuvoEnum.VALID_SOURCES.forEach(source -> {
1224 connector.sendQuery(source, NuvoCommand.NAME);
1225 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1226 connector.sendQuery(source, NuvoCommand.DISPINFO);
1227 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1228 connector.sendQuery(source, NuvoCommand.DISPLINE);
1229 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1230 } catch (NuvoException | InterruptedException e) {
1231 logger.debug("Error Querying Source data: {}", e.getMessage());
1235 // Query all active zones to get their current status and eq configuration
1236 activeZones.forEach(zoneNum -> {
1238 connector.sendQuery(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.STATUS);
1239 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1240 connector.sendCfgCommand(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.STATUS_QUERY, BLANK);
1241 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1242 connector.sendCfgCommand(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.EQ_QUERY, BLANK);
1243 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1244 } catch (NuvoException | InterruptedException e) {
1245 logger.debug("Error Querying Zone data: {}", e.getMessage());
1249 List<StateOption> sourceStateOptions = new ArrayList<>();
1250 sourceLabels.keySet().forEach(key -> {
1251 sourceStateOptions.add(new StateOption(key, sourceLabels.get(key)));
1254 // Put the source labels on all active zones
1255 activeZones.forEach(zoneNum -> {
1256 stateDescriptionProvider.setStateOptions(
1257 new ChannelUID(getThing().getUID(),
1258 ZONE.toLowerCase() + zoneNum + CHANNEL_DELIMIT + CHANNEL_TYPE_SOURCE),
1259 sourceStateOptions);
1261 } catch (NuvoException e) {
1262 logger.debug("Error polling status from Nuvo: {}", e.getMessage());
1269 * Cancel the reconnection job
1271 private void cancelReconnectJob() {
1272 ScheduledFuture<?> reconnectJob = this.reconnectJob;
1273 if (reconnectJob != null) {
1274 reconnectJob.cancel(true);
1275 this.reconnectJob = null;
1280 * Schedule the polling job
1282 private void schedulePollingJob() {
1286 logger.debug("MPS4 doesn't support polling");
1289 logger.debug("Schedule polling job");
1292 // when the Nuvo amp is off, this will keep the connection (esp Serial over IP) alive and detect if the
1293 // connection goes down
1294 pollingJob = scheduler.scheduleWithFixedDelay(() -> {
1295 if (connector.isConnected()) {
1296 logger.debug("Polling the component for updated status...");
1298 synchronized (sequenceLock) {
1300 connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
1301 } catch (NuvoException e) {
1302 logger.debug("Polling error: {}", e.getMessage());
1305 // if the last event received was more than 1.25 intervals ago,
1306 // the component is not responding even though the connection is still good
1307 if ((System.currentTimeMillis() - lastEventReceived) > (POLLING_INTERVAL_SEC * 1.25 * 1000)) {
1308 logger.debug("Component not responding to status requests");
1309 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1310 "Component not responding to status requests");
1312 scheduleReconnectJob();
1316 }, INITIAL_POLLING_DELAY_SEC, POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
1320 * Cancel the polling job
1322 private void cancelPollingJob() {
1323 ScheduledFuture<?> pollingJob = this.pollingJob;
1324 if (pollingJob != null) {
1325 pollingJob.cancel(true);
1326 this.pollingJob = null;
1331 * Schedule the clock sync job
1333 private void scheduleClockSyncJob() {
1334 logger.debug("Schedule clock sync job");
1335 cancelClockSyncJob();
1336 clockSyncJob = scheduler.scheduleWithFixedDelay(() -> {
1337 if (this.isGConcerto) {
1339 SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy,MM,dd,HH,mm");
1340 connector.sendCommand(NuvoCommand.CFGTIME.getValue() + dateFormat.format(new Date()));
1341 } catch (NuvoException e) {
1342 logger.debug("Error syncing clock: {}", e.getMessage());
1345 this.cancelClockSyncJob();
1347 }, INITIAL_CLOCK_SYNC_DELAY_SEC, CLOCK_SYNC_INTERVAL_SEC, TimeUnit.SECONDS);
1351 * Cancel the clock sync job
1353 private void cancelClockSyncJob() {
1354 ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
1355 if (clockSyncJob != null) {
1356 clockSyncJob.cancel(true);
1357 this.clockSyncJob = null;
1362 * Update the state of a channel (original method signature)
1364 * @param target the channel group
1365 * @param channelType the channel group item
1366 * @param value the value to be updated
1368 private void updateChannelState(NuvoEnum target, String channelType, String value) {
1369 updateChannelState(target, channelType, value, NO_ART);
1373 * Update the state of a channel (overloaded method to handle album_art channel)
1375 * @param target the channel group
1376 * @param channelType the channel group item
1377 * @param value the value to be updated
1378 * @param bytes the byte[] to load into the Image channel
1380 private void updateChannelState(NuvoEnum target, String channelType, String value, byte[] bytes) {
1381 String channel = target.name().toLowerCase() + CHANNEL_DELIMIT + channelType;
1383 if (!isLinked(channel)) {
1387 State state = UnDefType.UNDEF;
1389 if (UNDEF.equals(value)) {
1390 updateState(channel, state);
1394 switch (channelType) {
1395 case CHANNEL_TYPE_POWER:
1396 case CHANNEL_TYPE_MUTE:
1397 case CHANNEL_TYPE_DND:
1398 case CHANNEL_TYPE_PARTY:
1399 case CHANNEL_TYPE_ALLMUTE:
1400 case CHANNEL_TYPE_PAGE:
1401 case CHANNEL_TYPE_LOUDNESS:
1402 state = OnOffType.from(ON.equals(value));
1404 case CHANNEL_TYPE_LOCK:
1405 state = ON.equals(value) ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
1407 case CHANNEL_TYPE_SOURCE:
1408 case CHANNEL_TYPE_TREBLE:
1409 case CHANNEL_TYPE_BASS:
1410 case CHANNEL_TYPE_BALANCE:
1411 state = new DecimalType(value);
1413 case CHANNEL_TYPE_VOLUME:
1414 int volume = Integer.parseInt(value);
1415 long volumePct = Math
1416 .round((double) (MAX_VOLUME - volume) / (double) (MAX_VOLUME - MIN_VOLUME) * 100.0);
1417 state = new PercentType(BigDecimal.valueOf(volumePct));
1419 case CHANNEL_TYPE_BUTTONPRESS:
1420 case CHANNEL_DISPLAY_LINE1:
1421 case CHANNEL_DISPLAY_LINE2:
1422 case CHANNEL_DISPLAY_LINE3:
1423 case CHANNEL_DISPLAY_LINE4:
1424 case CHANNEL_BUTTON_PRESS:
1425 state = new StringType(value);
1427 case CHANNEL_PLAY_MODE:
1428 state = new StringType(NuvoStatusCodes.PLAY_MODE.get(value));
1430 case CHANNEL_TRACK_LENGTH:
1431 case CHANNEL_TRACK_POSITION:
1432 state = new QuantityType<>(Integer.parseInt(value) / 10, NuvoHandler.API_SECOND_UNIT);
1434 case CHANNEL_ALBUM_ART:
1435 state = new RawType(bytes, "image/jpeg");
1440 updateState(channel, state);
1444 * For grouped zones, update the source channel for all group members
1446 * @param zoneEnum the zone where the source was changed
1447 * @param srcId the new source number that was selected
1449 private void updateSrcForZoneGroup(NuvoEnum zoneEnum, String srcId) {
1450 // check if this zone is in a group, if so update the other group member's selected source
1451 nuvoGroupMap.forEach((groupId, groupZones) -> {
1452 if (groupZones.contains(zoneEnum)) {
1453 groupZones.forEach(z -> {
1454 if (!zoneEnum.equals(z)) {
1455 updateChannelState(z, CHANNEL_TYPE_SOURCE, srcId);
1463 * Handle a button press from a UI Player item
1465 * @param target the nuvo zone to receive the command
1466 * @param command the button press command to send to the zone
1468 private void handleControlCommand(NuvoEnum target, Command command) throws NuvoException {
1469 if (command instanceof PlayPauseType) {
1470 connector.sendCommand(target, NuvoCommand.PLAYPAUSE);
1471 } else if (command instanceof NextPreviousType) {
1472 if (command == NextPreviousType.NEXT) {
1473 connector.sendCommand(target, NuvoCommand.NEXT);
1474 } else if (command == NextPreviousType.PREVIOUS) {
1475 connector.sendCommand(target, NuvoCommand.PREV);
1478 logger.warn("Unknown control command: {}", command);
1483 * Scrapes the MPS4's json api to retrieve the currently playing media's album art
1485 * @param source the source that should be queried to load the current album art
1487 private void getMps4AlbumArt(NuvoEnum source) {
1488 final String clientId = UUID.randomUUID().toString();
1490 // try to get cached source instance
1491 String instance = sourceInstanceMap.get(source);
1493 // if not found, need to retrieve from the api, once found these calls will be skipped
1494 if (instance == null) {
1495 // find which zone is using this source
1496 NuvoEnum zone = sourceZoneMap.get(source);
1499 logger.debug("Unable to determine zone that is using source {}", source);
1503 final String json = getMcsJson(String.format(GET_MCS_INSTANCE, mps4Host, zone.getNum(), clientId),
1506 Matcher matcher = MCS_INSTANCE_PATTERN.matcher(json);
1507 if (matcher.find()) {
1508 instance = matcher.group(1);
1509 sourceInstanceMap.put(source, instance);
1510 logger.debug("Found instance '{}' for source {}", instance, source);
1512 logger.debug("No instance match found for json: {}", json);
1515 } catch (TimeoutException | ExecutionException e) {
1516 logger.debug("Failed getting instance name", e);
1518 } catch (InterruptedException e) {
1519 logger.debug("InterruptedException getting instance name", e);
1520 Thread.currentThread().interrupt();
1527 logger.debug("Using MCS instance '{}' for source {}", instance, source);
1528 final String json = getMcsJson(String.format(GET_MCS_STATUS, mps4Host, instance, clientId), clientId);
1530 if (json.contains("\"name\":\"PlayState\",\"value\":1}")
1531 || json.contains("\"name\":\"PlayState\",\"value\":3}")) {
1532 Matcher matcher = ART_GUID_PATTERN.matcher(json);
1533 if (matcher.find()) {
1534 final String nowPlayingGuid = matcher.group(1);
1536 // If streaming (NowPlayingType=Radio), always retrieve because the same NowPlayingGuid can
1537 // get a different image written to it by Gracenote when the track changes
1538 if (!mps4ArtGuids.get(source).equals(nowPlayingGuid)
1539 || json.contains("NowPlayingType\",\"value\":\"Radio\"}")) {
1540 ContentResponse artResponse = httpClient
1541 .newRequest(String.format(GET_MCS_ART, mps4Host, nowPlayingGuid, instance)).method(GET)
1542 .timeout(10, TimeUnit.SECONDS).send();
1544 if (artResponse.getStatus() == OK_200) {
1545 logger.debug("Retrieved album art for guid: {}", nowPlayingGuid);
1546 updateChannelState(source, CHANNEL_ALBUM_ART, BLANK, artResponse.getContent());
1547 mps4ArtGuids.put(source, nowPlayingGuid);
1550 logger.debug("Album art has not changed, guid: {}", nowPlayingGuid);
1553 logger.debug("NowPlayingGuid not found");
1556 logger.debug("PlayState not valid");
1558 } catch (TimeoutException | ExecutionException e) {
1559 logger.debug("Failed getting album art", e);
1560 updateChannelState(source, CHANNEL_ALBUM_ART, UNDEF);
1561 mps4ArtGuids.put(source, BLANK);
1562 } catch (InterruptedException e) {
1563 logger.debug("InterruptedException getting album art", e);
1564 updateChannelState(source, CHANNEL_ALBUM_ART, UNDEF);
1565 mps4ArtGuids.put(source, BLANK);
1566 Thread.currentThread().interrupt();
1571 * Used by getMps4AlbumArt to abstract retrieval of status json from MCS
1573 * @param commandUrl the url with the embedded commands to send to MCS
1574 * @param clientId the current clientId
1575 * @return string json result from the command executed
1577 * @throws InterruptedException
1578 * @throws TimeoutException
1579 * @throws ExecutionException
1581 private String getMcsJson(String commandUrl, String clientId)
1582 throws InterruptedException, TimeoutException, ExecutionException {
1583 ContentResponse commandResp = httpClient.newRequest(commandUrl).method(GET).timeout(10, TimeUnit.SECONDS)
1586 if (commandResp.getStatus() == OK_200) {
1587 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1588 ContentResponse jsonResp = httpClient.newRequest(String.format(GET_MCS_JSON, mps4Host, clientId))
1589 .method(GET).timeout(10, TimeUnit.SECONDS).send();
1590 if (jsonResp.getStatus() == OK_200) {
1591 return jsonResp.getContentAsString();
1593 logger.debug("Got error response {} when getting json from MCS", commandResp.getStatus());
1597 logger.debug("Got error response {} when sending json command url: {}", commandResp.getStatus(), commandUrl);