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.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(.*)$");
140 private static final Pattern MCS_INSTANCE_PATTERN = Pattern.compile("MCSInstance\",\"value\":\"(.*?)\"");
141 private static final Pattern ART_GUID_PATTERN = Pattern.compile("NowPlayingGuid\",\"value\":\"\\{(.*?)\\}\"\\}");
143 private final Logger logger = LoggerFactory.getLogger(NuvoHandler.class);
144 private final NuvoStateDescriptionOptionProvider stateDescriptionProvider;
145 private final SerialPortManager serialPortManager;
146 private final HttpClient httpClient;
148 private @Nullable ScheduledFuture<?> reconnectJob;
149 private @Nullable ScheduledFuture<?> pollingJob;
150 private @Nullable ScheduledFuture<?> clockSyncJob;
151 private @Nullable ScheduledFuture<?> pingJob;
153 private NuvoConnector connector = new NuvoDefaultConnector();
154 private long lastEventReceived = System.currentTimeMillis();
155 private int numZones = 1;
156 private String versionString = BLANK;
157 private boolean isGConcerto = false;
158 private Object sequenceLock = new Object();
160 private boolean isAnyOhNuvoNet = false;
161 private NuvoMenu nuvoMenus = new NuvoMenu();
162 private HashMap<String, Set<NuvoEnum>> nuvoGroupMap = new HashMap<>();
163 private HashMap<NuvoEnum, Integer> nuvoNetSrcMap = new HashMap<>();
164 private HashMap<NuvoEnum, String> favPrefixMap = new HashMap<>();
165 private HashMap<NuvoEnum, String[]> favoriteMap = new HashMap<>();
166 private HashMap<NuvoEnum, NuvoEnum> sourceZoneMap = new HashMap<>();
167 private HashMap<NuvoEnum, String> sourceInstanceMap = new HashMap<>();
169 private HashMap<NuvoEnum, byte[]> albumArtMap = new HashMap<>();
170 private HashMap<NuvoEnum, Integer> albumArtIds = new HashMap<>();
171 private HashMap<NuvoEnum, String> dispInfoCache = new HashMap<>();
172 private HashMap<NuvoEnum, String> mps4ArtGuids = new HashMap<>();
174 Set<Integer> activeZones = new HashSet<>(1);
176 // A tree map that maps the source ids to source labels
177 TreeMap<String, String> sourceLabels = new TreeMap<>();
179 // Indicates if there is a need to poll status because of a disconnection used for MPS4 systems
180 boolean pollStatusNeeded = true;
181 boolean isMps4 = false;
182 String mps4Host = BLANK;
187 public NuvoHandler(Thing thing, NuvoStateDescriptionOptionProvider stateDescriptionProvider,
188 SerialPortManager serialPortManager, HttpClient httpClient) {
190 this.stateDescriptionProvider = stateDescriptionProvider;
191 this.serialPortManager = serialPortManager;
192 this.httpClient = httpClient;
196 public void initialize() {
197 final String uid = this.getThing().getUID().getAsString();
198 NuvoThingConfiguration config = getConfigAs(NuvoThingConfiguration.class);
199 final String serialPort = config.serialPort;
200 final String host = config.host;
201 final Integer port = config.port;
202 final Integer numZones = config.numZones;
204 // Check configuration settings
205 String configError = null;
206 if ((serialPort == null || serialPort.isEmpty()) && (host == null || host.isEmpty())) {
207 configError = "undefined serialPort and host configuration settings; please set one of them";
208 } else if (serialPort != null && (host == null || host.isEmpty())) {
209 if (serialPort.toLowerCase().startsWith("rfc2217")) {
210 configError = "use host and port configuration settings for a serial over IP connection";
214 configError = "undefined port configuration setting";
215 } else if (port <= 0) {
216 configError = "invalid port configuration setting";
220 if (configError != null) {
221 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configError);
225 if (serialPort != null && !serialPort.isEmpty()) {
226 connector = new NuvoSerialConnector(serialPortManager, serialPort, uid);
227 } else if (host != null && port != null) {
228 connector = new NuvoIpConnector(host, port, uid);
229 this.isMps4 = (port.intValue() == MPS4_PORT);
232 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
233 "Either Serial port or Host & Port must be specifed");
237 nuvoNetSrcMap.put(NuvoEnum.SOURCE1, config.nuvoNetSrc1);
238 nuvoNetSrcMap.put(NuvoEnum.SOURCE2, config.nuvoNetSrc2);
239 nuvoNetSrcMap.put(NuvoEnum.SOURCE3, config.nuvoNetSrc3);
240 nuvoNetSrcMap.put(NuvoEnum.SOURCE4, config.nuvoNetSrc4);
241 nuvoNetSrcMap.put(NuvoEnum.SOURCE5, config.nuvoNetSrc5);
242 nuvoNetSrcMap.put(NuvoEnum.SOURCE6, config.nuvoNetSrc6);
244 nuvoGroupMap.put("1", new HashSet<>());
245 nuvoGroupMap.put("2", new HashSet<>());
246 nuvoGroupMap.put("3", new HashSet<>());
247 nuvoGroupMap.put("4", new HashSet<>());
250 logger.debug("Port set to {} configuring binding for MPS4 compatability", MPS4_PORT);
252 this.isAnyOhNuvoNet = (config.nuvoNetSrc1.equals(2) || config.nuvoNetSrc2.equals(2)
253 || config.nuvoNetSrc3.equals(2) || config.nuvoNetSrc4.equals(2) || config.nuvoNetSrc5.equals(2)
254 || config.nuvoNetSrc6.equals(2));
256 mps4ArtGuids.put(NuvoEnum.SOURCE1, BLANK);
257 mps4ArtGuids.put(NuvoEnum.SOURCE2, BLANK);
258 mps4ArtGuids.put(NuvoEnum.SOURCE3, BLANK);
259 mps4ArtGuids.put(NuvoEnum.SOURCE4, BLANK);
260 mps4ArtGuids.put(NuvoEnum.SOURCE5, BLANK);
261 mps4ArtGuids.put(NuvoEnum.SOURCE6, BLANK);
263 if (this.isAnyOhNuvoNet) {
264 logger.debug("At least one source is configured as an openHAB NuvoNet source");
265 connector.setAnyOhNuvoNet(true);
266 loadMenuConfiguration(config);
268 favoriteMap.put(NuvoEnum.SOURCE1,
269 !config.favoritesSrc1.isEmpty() ? config.favoritesSrc1.split(COMMA) : new String[0]);
270 favoriteMap.put(NuvoEnum.SOURCE2,
271 !config.favoritesSrc2.isEmpty() ? config.favoritesSrc2.split(COMMA) : new String[0]);
272 favoriteMap.put(NuvoEnum.SOURCE3,
273 !config.favoritesSrc3.isEmpty() ? config.favoritesSrc3.split(COMMA) : new String[0]);
274 favoriteMap.put(NuvoEnum.SOURCE4,
275 !config.favoritesSrc4.isEmpty() ? config.favoritesSrc4.split(COMMA) : new String[0]);
276 favoriteMap.put(NuvoEnum.SOURCE5,
277 !config.favoritesSrc5.isEmpty() ? config.favoritesSrc5.split(COMMA) : new String[0]);
278 favoriteMap.put(NuvoEnum.SOURCE6,
279 !config.favoritesSrc6.isEmpty() ? config.favoritesSrc6.split(COMMA) : new String[0]);
281 favPrefixMap.put(NuvoEnum.SOURCE1, config.favPrefix1);
282 favPrefixMap.put(NuvoEnum.SOURCE2, config.favPrefix2);
283 favPrefixMap.put(NuvoEnum.SOURCE3, config.favPrefix3);
284 favPrefixMap.put(NuvoEnum.SOURCE4, config.favPrefix4);
285 favPrefixMap.put(NuvoEnum.SOURCE5, config.favPrefix5);
286 favPrefixMap.put(NuvoEnum.SOURCE6, config.favPrefix6);
288 albumArtIds.put(NuvoEnum.SOURCE1, 0);
289 albumArtIds.put(NuvoEnum.SOURCE2, 0);
290 albumArtIds.put(NuvoEnum.SOURCE3, 0);
291 albumArtIds.put(NuvoEnum.SOURCE4, 0);
292 albumArtIds.put(NuvoEnum.SOURCE5, 0);
293 albumArtIds.put(NuvoEnum.SOURCE6, 0);
295 albumArtMap.put(NuvoEnum.SOURCE1, NO_ART);
296 albumArtMap.put(NuvoEnum.SOURCE2, NO_ART);
297 albumArtMap.put(NuvoEnum.SOURCE3, NO_ART);
298 albumArtMap.put(NuvoEnum.SOURCE4, NO_ART);
299 albumArtMap.put(NuvoEnum.SOURCE5, NO_ART);
300 albumArtMap.put(NuvoEnum.SOURCE6, NO_ART);
304 if (numZones != null) {
305 this.numZones = numZones;
308 activeZones = IntStream.range((1), (this.numZones + 1)).boxed().collect(Collectors.toSet());
310 // remove the channels for the zones we are not using
311 if (this.numZones < MAX_ZONES) {
312 List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
314 List<Integer> zonesToRemove = IntStream.range((this.numZones + 1), (MAX_ZONES + 1)).boxed()
315 .collect(Collectors.toList());
317 zonesToRemove.forEach(zone -> channels.removeIf(c -> (c.getUID().getId().contains("zone" + zone))));
318 updateThing(editThing().withChannels(channels).build());
321 // Build a list of State options for the global favorites using user config values (if supplied)
322 String[] favoritesArr = !config.favoriteLabels.isEmpty() ? config.favoriteLabels.split(COMMA) : new String[0];
323 List<StateOption> favoriteLabelsStateOptions = new ArrayList<>();
324 for (int i = 0; i < MAX_FAV; i++) {
325 if (favoritesArr.length > i) {
326 favoriteLabelsStateOptions.add(new StateOption(String.valueOf(i + 1), favoritesArr[i]));
327 } else if (favoritesArr.length == 0) {
328 favoriteLabelsStateOptions.add(new StateOption(String.valueOf(i + 1), "Favorite " + (i + 1)));
332 // Also add any openHAB NuvoNet source favorites to the list
333 for (int src = 1; src <= MAX_SRC; src++) {
334 NuvoEnum source = NuvoEnum.valueOf(SOURCE + src);
335 String[] favorites = favoriteMap.get(source);
336 if (favorites != null) {
337 for (int fav = 0; fav < favorites.length; fav++) {
338 favoriteLabelsStateOptions.add(new StateOption(String.valueOf(src * 100 + fav),
339 favPrefixMap.get(source) + favorites[fav]));
344 // Put the global favorites labels on all active zones
345 activeZones.forEach(zoneNum -> {
346 stateDescriptionProvider.setStateOptions(
347 new ChannelUID(getThing().getUID(),
348 ZONE.toLowerCase() + zoneNum + CHANNEL_DELIMIT + CHANNEL_TYPE_FAVORITE),
349 favoriteLabelsStateOptions);
352 if (config.clockSync) {
353 scheduleClockSyncJob();
356 scheduleReconnectJob();
357 schedulePollingJob();
358 schedulePingTimeoutJob();
359 updateStatus(ThingStatus.UNKNOWN);
363 public void dispose() {
364 if (this.isAnyOhNuvoNet) {
366 // disable NuvoNet for each source that was configured as an openHAB NuvoNet source
367 nuvoNetSrcMap.forEach((source, val) -> {
370 connector.sendCommand(source.getId() + "DISPINFOTWO0,0,0,0,0,0,0");
371 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
372 connector.sendCommand(
373 source.getId() + "DISPLINES0,0,0,\"Source Unavailable\",\"\",\"\",\"\"");
374 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
375 connector.sendCommand(source.getConfigId() + "NUVONET0");
376 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
377 } catch (NuvoException | InterruptedException e) {
378 logger.debug("Error sending command to disable NuvoNet source: {}", source.getNum());
383 // need '1' flag for sources configured as an MPS4 NuvoNet source, but disable openHAB NuvoNet sources
384 connector.sendCommand("SNUMBERS" + (nuvoNetSrcMap.get(NuvoEnum.SOURCE1).equals(1) ? ONE : ZERO) + COMMA
385 + (nuvoNetSrcMap.get(NuvoEnum.SOURCE2).equals(1) ? ONE : ZERO) + COMMA
386 + (nuvoNetSrcMap.get(NuvoEnum.SOURCE3).equals(1) ? ONE : ZERO) + COMMA
387 + (nuvoNetSrcMap.get(NuvoEnum.SOURCE4).equals(1) ? ONE : ZERO) + COMMA
388 + (nuvoNetSrcMap.get(NuvoEnum.SOURCE5).equals(1) ? ONE : ZERO) + COMMA
389 + (nuvoNetSrcMap.get(NuvoEnum.SOURCE6).equals(1) ? ONE : ZERO));
390 } catch (NuvoException e) {
391 logger.debug("Error sending SNUMBERS command to disable NuvoNet sources");
395 cancelReconnectJob();
397 cancelClockSyncJob();
398 cancelPingTimeoutJob();
404 public Collection<Class<? extends ThingHandlerService>> getServices() {
405 return List.of(NuvoThingActions.class);
408 public void handleRawCommand(String command) {
409 synchronized (sequenceLock) {
411 connector.sendCommand(command);
412 } catch (NuvoException e) {
413 logger.warn("Nuvo Command: {} failed", command);
419 * Handle a command from the UI
421 * @param channelUID the channel sending the command
422 * @param command the command received
426 public void handleCommand(ChannelUID channelUID, Command command) {
427 String channel = channelUID.getId();
428 String[] channelSplit = channel.split(CHANNEL_DELIMIT);
429 NuvoEnum target = NuvoEnum.valueOf(channelSplit[0].toUpperCase());
431 String channelType = channelSplit[1];
433 if (getThing().getStatus() != ThingStatus.ONLINE) {
434 logger.debug("Thing is not ONLINE; command {} from channel {} is ignored", command, channel);
438 synchronized (sequenceLock) {
439 if (!connector.isConnected()) {
440 logger.warn("Command {} from channel {} is ignored: connection not established", command, channel);
445 switch (channelType) {
446 case CHANNEL_TYPE_POWER:
447 if (command instanceof OnOffType) {
448 connector.sendCommand(target, command == OnOffType.ON ? NuvoCommand.ON : NuvoCommand.OFF);
451 case CHANNEL_TYPE_SOURCE:
452 if (command instanceof DecimalType decimalCommand) {
453 int value = decimalCommand.intValue();
454 if (value >= 1 && value <= MAX_SRC) {
455 logger.debug("Got source command {} zone {}", value, target);
456 connector.sendCommand(target, NuvoCommand.SOURCE, String.valueOf(value));
458 // update the other group member's selected source
459 updateSrcForZoneGroup(target, String.valueOf(value));
460 sourceZoneMap.put(NuvoEnum.valueOf(SOURCE + value), target);
464 case CHANNEL_TYPE_FAVORITE:
465 if (command instanceof DecimalType decimalCommand) {
466 int value = decimalCommand.intValue();
467 if (value >= 1 && value <= MAX_FAV) {
468 logger.debug("Got favorite command {} zone {}", value, target);
469 connector.sendCommand(target, NuvoCommand.FAVORITE, String.valueOf(value));
470 } else if (value >= 100 && value <= 650) {
471 String sourceNum = String.valueOf(value / 100);
472 NuvoEnum source = NuvoEnum.valueOf(SOURCE + sourceNum);
473 updateChannelState(source, CHANNEL_BUTTON_PRESS,
474 PLAY_MUSIC_PRESET + favoriteMap.get(source)[value % 100]);
475 connector.sendCommand(target, NuvoCommand.SOURCE, sourceNum);
477 // if this zone is in a group, update the other group member's selected source
478 updateSrcForZoneGroup(target, sourceNum);
482 case CHANNEL_TYPE_VOLUME:
483 if (command instanceof PercentType percentCommand) {
484 int value = (MAX_VOLUME
485 - (int) Math.round(percentCommand.doubleValue() / 100.0 * (MAX_VOLUME - MIN_VOLUME))
487 logger.debug("Got volume command {} zone {}", value, target);
488 connector.sendCommand(target, NuvoCommand.VOLUME, String.valueOf(value));
491 case CHANNEL_TYPE_MUTE:
492 if (command instanceof OnOffType) {
493 connector.sendCommand(target,
494 command == OnOffType.ON ? NuvoCommand.MUTE_ON : NuvoCommand.MUTE_OFF);
497 case CHANNEL_TYPE_TREBLE:
498 if (command instanceof DecimalType decimalCommand) {
499 int value = decimalCommand.intValue();
500 if (value >= MIN_EQ && value <= MAX_EQ) {
501 // device can only accept even values
502 if (value % 2 == 1) {
505 logger.debug("Got treble command {} zone {}", value, target);
506 connector.sendCfgCommand(target, NuvoCommand.TREBLE, String.valueOf(value));
510 case CHANNEL_TYPE_BASS:
511 if (command instanceof DecimalType decimalCommand) {
512 int value = decimalCommand.intValue();
513 if (value >= MIN_EQ && value <= MAX_EQ) {
514 if (value % 2 == 1) {
517 logger.debug("Got bass command {} zone {}", value, target);
518 connector.sendCfgCommand(target, NuvoCommand.BASS, String.valueOf(value));
522 case CHANNEL_TYPE_BALANCE:
523 if (command instanceof DecimalType decimalCommand) {
524 int value = decimalCommand.intValue();
525 if (value >= MIN_EQ && value <= MAX_EQ) {
526 if (value % 2 == 1) {
529 logger.debug("Got balance command {} zone {}", value, target);
530 connector.sendCfgCommand(target, NuvoCommand.BALANCE,
531 NuvoStatusCodes.getBalanceFromInt(value));
535 case CHANNEL_TYPE_LOUDNESS:
536 if (command instanceof OnOffType) {
537 connector.sendCfgCommand(target, NuvoCommand.LOUDNESS,
538 command == OnOffType.ON ? ONE : ZERO);
541 case CHANNEL_TYPE_CONTROL:
542 handleControlCommand(target, command);
544 case CHANNEL_TYPE_DND:
545 if (command instanceof OnOffType) {
546 connector.sendCommand(target,
547 command == OnOffType.ON ? NuvoCommand.DND_ON : NuvoCommand.DND_OFF);
550 case CHANNEL_TYPE_PARTY:
551 if (command instanceof OnOffType) {
552 connector.sendCommand(target,
553 command == OnOffType.ON ? NuvoCommand.PARTY_ON : NuvoCommand.PARTY_OFF);
556 case CHANNEL_DISPLAY_LINE1:
557 if (command instanceof StringType) {
558 connector.sendCommand(target, NuvoCommand.DISPLINE1, "\"" + command + "\"");
561 case CHANNEL_DISPLAY_LINE2:
562 if (command instanceof StringType) {
563 connector.sendCommand(target, NuvoCommand.DISPLINE2, "\"" + command + "\"");
566 case CHANNEL_DISPLAY_LINE3:
567 if (command instanceof StringType) {
568 connector.sendCommand(target, NuvoCommand.DISPLINE3, "\"" + command + "\"");
571 case CHANNEL_DISPLAY_LINE4:
572 if (command instanceof StringType) {
573 connector.sendCommand(target, NuvoCommand.DISPLINE4, "\"" + command + "\"");
576 case CHANNEL_TYPE_ALLOFF:
577 if (command instanceof OnOffType) {
578 connector.sendCommand(NuvoCommand.ALLOFF);
581 case CHANNEL_TYPE_ALLMUTE:
582 if (command instanceof OnOffType) {
583 connector.sendCommand(
584 command == OnOffType.ON ? NuvoCommand.ALLMUTE_ON : NuvoCommand.ALLMUTE_OFF);
587 case CHANNEL_TYPE_PAGE:
588 if (command instanceof OnOffType) {
589 connector.sendCommand(command == OnOffType.ON ? NuvoCommand.PAGE_ON : NuvoCommand.PAGE_OFF);
592 case CHANNEL_TYPE_SENDCMD:
593 if (command instanceof StringType) {
594 String commandStr = command.toString();
595 if (commandStr.contains(DISP_INFO_TWO)) {
596 NuvoEnum source = NuvoEnum
597 .valueOf(commandStr.split(DISP_INFO_TWO)[0].replace("S", SOURCE));
598 dispInfoCache.put(source, commandStr);
600 // if 'albumartid' is present, substitute it with the albumArtId hex string
601 connector.sendCommand(commandStr.replace(ALBUM_ART_ID,
602 (OFFSET_ZERO + Integer.toHexString(albumArtIds.get(source)))));
604 connector.sendCommand(commandStr);
608 case CHANNEL_ART_URL:
609 if (command instanceof StringType) {
610 String url = command.toString();
611 if (url.startsWith(HTTP) || url.startsWith(HTTPS)) {
613 ContentResponse contentResponse = httpClient.newRequest(url).method(GET)
614 .timeout(10, TimeUnit.SECONDS).send();
615 int httpStatus = contentResponse.getStatus();
616 if (httpStatus == OK_200) {
617 albumArtMap.put(target,
618 NuvoImageResizer.resizeImage(contentResponse.getContent(), 80, 80));
620 updateChannelState(target, CHANNEL_ALBUM_ART, BLANK,
621 contentResponse.getContent());
623 albumArtMap.put(target, NO_ART);
624 albumArtIds.put(target, 0);
625 updateChannelState(target, CHANNEL_ALBUM_ART, UNDEF);
628 } catch (InterruptedException | TimeoutException | ExecutionException e) {
629 albumArtMap.put(target, NO_ART);
630 albumArtIds.put(target, 0);
631 updateChannelState(target, CHANNEL_ALBUM_ART, UNDEF);
634 albumArtIds.put(target, Math.abs(url.hashCode()));
636 // re-send the cached DISPINFOTWO message, substituting in the new albumArtId
637 if (dispInfoCache.get(target) != null) {
638 connector.sendCommand(dispInfoCache.get(target).replace(ALBUM_ART_ID,
639 (OFFSET_ZERO + Integer.toHexString(albumArtIds.get(target)))));
642 albumArtMap.put(target, NO_ART);
643 albumArtIds.put(target, 0);
644 updateChannelState(target, CHANNEL_ALBUM_ART, UNDEF);
648 case CHANNEL_SOURCE_MENU:
649 if (command instanceof StringType) {
650 updateChannelState(target, CHANNEL_BUTTON_PRESS, command.toString());
653 } catch (NuvoException e) {
654 logger.warn("Command {} from channel {} failed: {}", command, channel, e.getMessage());
655 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Sending command failed");
657 scheduleReconnectJob();
663 * Open the connection with the Nuvo device
665 * @return true if the connection is opened successfully or false if not
667 private synchronized boolean openConnection() {
668 connector.addEventListener(this);
671 } catch (NuvoException e) {
672 logger.debug("openConnection() failed: {}", e.getMessage());
674 logger.debug("openConnection(): {}", connector.isConnected() ? "connected" : "disconnected");
675 return connector.isConnected();
679 * Close the connection with the Nuvo device
681 private synchronized void closeConnection() {
682 if (connector.isConnected()) {
684 connector.removeEventListener(this);
685 pollStatusNeeded = true;
686 logger.debug("closeConnection(): disconnected");
691 * Handle an event received from the Nuvo device
693 * @param evt the event to process
696 public void onNewMessageEvent(NuvoMessageEvent evt) {
697 logger.debug("onNewMessageEvent: zone {}, source {}, value {}", evt.getZone(), evt.getSrc(), evt.getValue());
698 lastEventReceived = System.currentTimeMillis();
700 final NuvoEnum zone = !evt.getZone().isEmpty() ? NuvoEnum.valueOf(ZONE + evt.getZone()) : NuvoEnum.SYSTEM;
701 final NuvoEnum source = !evt.getSrc().isEmpty() ? NuvoEnum.valueOf(SOURCE + evt.getSrc()) : NuvoEnum.SYSTEM;
702 final String sourceZone = source.getId() + zone.getId();
703 final String updateData = evt.getValue().trim();
705 if (this.getThing().getStatus() != ThingStatus.ONLINE) {
706 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, this.versionString);
709 switch (evt.getType()) {
711 this.versionString = updateData;
712 // Determine if we are a Grand Concerto or not
713 if (this.versionString.contains(GC_STR)) {
714 logger.debug("Grand Concerto detected");
715 this.isGConcerto = true;
716 connector.setEssentia(false);
718 logger.debug("Grand Concerto not detected");
722 logger.debug("Restart message received; re-sending initialization messages");
723 enableNuvonet(false);
726 logger.debug("Ping message received- rescheduling ping timeout");
727 schedulePingTimeoutJob();
728 // Return here because receiving a ping does not indicate that one can poll
731 activeZones.forEach(zoneNum -> {
732 updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_POWER, OFF);
735 // Publish the ALLOFF event to all button channels for awareness in source rules
736 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, ZERO + COMMA + ALLOFF);
737 NuvoEnum.VALID_SOURCES.forEach(src -> {
738 updateChannelState(src, CHANNEL_BUTTON_PRESS, ALLOFF);
743 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_ALLMUTE, ONE.equals(updateData) ? ON : OFF);
744 activeZones.forEach(zoneNum -> {
745 updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_MUTE,
746 ONE.equals(updateData) ? ON : OFF);
750 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_PAGE, ONE.equals(updateData) ? ON : OFF);
752 case TYPE_SOURCE_UPDATE:
753 logger.debug("Source update: Source: {} - Value: {}", source.getNum(), updateData);
755 if (updateData.contains(DISPLINE)) {
756 // example: DISPLINE2,"Play My Song (Featuring Dee Ajayi)"
757 Matcher matcher = DISP_PATTERN.matcher(updateData);
758 if (matcher.find()) {
759 updateChannelState(source, CHANNEL_DISPLAY_LINE + matcher.group(1), matcher.group(2));
761 logger.debug("no match on message: {}", updateData);
763 } else if (updateData.contains(DISPINFO)) {
764 // example: DISPINFO,DUR0,POS70,STATUS2 (DUR and POS are expressed in tenths of a second)
765 // 6 places(tenths of a second)-> max 999,999 /10/60/60/24 = 1.15 days
766 Matcher matcher = DISP_INFO_PATTERN.matcher(updateData);
767 if (matcher.find()) {
768 updateChannelState(source, CHANNEL_TRACK_LENGTH, matcher.group(1));
769 updateChannelState(source, CHANNEL_TRACK_POSITION, matcher.group(2));
770 updateChannelState(source, CHANNEL_PLAY_MODE, matcher.group(3));
772 // if this is an MPS4 source, the following retrieves album art when the source is playing
773 if (nuvoNetSrcMap.get(source) == 1
774 && isLinked(source.name().toLowerCase() + CHANNEL_DELIMIT + CHANNEL_ALBUM_ART)) {
775 if (MPS4_PLAYING_MODES.contains(matcher.group(3))) {
776 logger.debug("DISPINFO update, trying to get album art");
777 getMps4AlbumArt(source);
778 } else if (MPS4_IDLE_MODES.contains(matcher.group(3)) && ZERO.equals(matcher.group(1))) {
779 // clear album art channel for this source
780 logger.debug("DISPINFO update- not playing; clearing art, mode: {}", matcher.group(3));
781 updateChannelState(source, CHANNEL_ALBUM_ART, UNDEF);
782 mps4ArtGuids.put(source, BLANK);
786 logger.debug("no match on message: {}", updateData);
788 } else if (updateData.contains(NAME_QUOTE)) {
789 // example: NAME"Ipod"
790 String name = updateData.split("\"")[1];
791 sourceLabels.put(String.valueOf(source.getNum()), name);
794 case TYPE_ZONE_UPDATE:
795 logger.debug("Zone update: Zone: {} - Value: {}", zone.getNum(), updateData);
797 // or: ON,SRC3,VOL63,DND0,LOCK0
798 // or: ON,SRC3,MUTE,DND0,LOCK0
800 if (OFF.equals(updateData)) {
801 updateChannelState(zone, CHANNEL_TYPE_POWER, OFF);
802 updateChannelState(zone, CHANNEL_TYPE_SOURCE, UNDEF);
804 Matcher matcher = ZONE_PATTERN.matcher(updateData);
805 if (matcher.find()) {
806 updateChannelState(zone, CHANNEL_TYPE_POWER, ON);
807 updateChannelState(zone, CHANNEL_TYPE_SOURCE, matcher.group(1));
808 sourceZoneMap.put(NuvoEnum.valueOf(SOURCE + matcher.group(1)), zone);
810 // update the other group member's selected source
811 updateSrcForZoneGroup(zone, matcher.group(1));
813 if (MUTE.equals(matcher.group(2))) {
814 updateChannelState(zone, CHANNEL_TYPE_MUTE, ON);
816 updateChannelState(zone, CHANNEL_TYPE_MUTE, NuvoCommand.OFF.getValue());
817 updateChannelState(zone, CHANNEL_TYPE_VOLUME, matcher.group(2).replace(VOL, BLANK));
820 updateChannelState(zone, CHANNEL_TYPE_DND, ONE.equals(matcher.group(3)) ? ON : OFF);
821 updateChannelState(zone, CHANNEL_TYPE_LOCK, ONE.equals(matcher.group(4)) ? ON : OFF);
823 logger.debug("no match on message: {}", updateData);
827 case TYPE_ZONE_SOURCE_BUTTON:
828 logger.debug("Source Button pressed: Source: {} - Button: {}", source.getNum(), updateData);
829 updateChannelState(source, CHANNEL_BUTTON_PRESS, updateData);
830 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, zone.getNum() + COMMA + updateData);
833 String buttonAction = NuvoStatusCodes.BUTTON_CODE.get(updateData);
835 if (buttonAction != null) {
836 logger.debug("NuvoNet Source Button pressed: Source: {} - Button: {}", source.getNum(),
838 updateChannelState(source, CHANNEL_BUTTON_PRESS, buttonAction);
839 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, zone.getNum() + COMMA + buttonAction);
841 logger.debug("NuvoNet Source Button pressed: Source: {} - Unknown button code: {}", source.getNum(),
843 updateChannelState(source, CHANNEL_BUTTON_PRESS, updateData);
844 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, zone.getNum() + COMMA + updateData);
847 case TYPE_NN_MENU_ITEM_SELECTED:
848 // ignore this update unless openHAB is handling this source
849 if (nuvoNetSrcMap.get(source).equals(2)) {
850 String[] updateDataSplit = updateData.split(COMMA);
851 String menuId = updateDataSplit[0];
852 int menuItemIdx = Integer.parseInt(updateDataSplit[1]) - 1;
854 boolean exitMenu = false;
855 if ("0xFFFFFFFF".equals(menuId)) {
856 TopMenu topMenuItem = nuvoMenus.getSource().get(source.getNum() - 1).getTopMenu()
858 logger.debug("Top Menu item selected: Source: {} - Menu Item: {}", source.getNum(),
859 topMenuItem.getText());
860 updateChannelState(source, CHANNEL_BUTTON_PRESS, topMenuItem.getText());
861 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS,
862 zone.getNum() + COMMA + topMenuItem.getText());
864 List<String> subMenuItems = topMenuItem.getItems();
866 if (subMenuItems.isEmpty()) {
869 // send submenu (maximum of 20 items)
870 int subMenuSize = subMenuItems.size() < 20 ? subMenuItems.size() : 20;
872 connector.sendCommand(sourceZone + "MENU" + (menuItemIdx + 11) + ",0,0," + subMenuSize
873 + ",0,0," + subMenuSize + ",\"" + topMenuItem.getText() + "\"");
874 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
876 for (int i = 0; i < subMenuSize; i++) {
877 connector.sendCommand(
878 sourceZone + "MENUITEM" + (i + 1) + ",0,0,\"" + subMenuItems.get(i) + "\"");
880 } catch (NuvoException | InterruptedException e) {
881 logger.debug("Error sending sub menu to {}", sourceZone);
885 // a sub menu item was selected
886 TopMenu topMenuItem = nuvoMenus.getSource().get(source.getNum() - 1).getTopMenu()
887 .get(Integer.decode(menuId) - 11);
888 String subMenuItem = topMenuItem.getItems().get(menuItemIdx);
890 logger.debug("Sub Menu item selected: Source: {} - Menu Item: {}", source.getNum(),
891 topMenuItem.getText() + "|" + subMenuItem);
892 updateChannelState(source, CHANNEL_BUTTON_PRESS, topMenuItem.getText() + "|" + subMenuItem);
893 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS,
894 zone.getNum() + COMMA + topMenuItem.getText() + "|" + subMenuItem);
900 // tell the zone to exit the menu
901 connector.sendCommand(sourceZone + "MENU0,0,0,0,0,0,0,\"\"");
902 } catch (NuvoException e) {
903 logger.debug("Error sending exit menu command to {}", sourceZone);
908 case TYPE_NN_MENUREQ:
909 // ignore this update unless openHAB is handling this source
910 if (nuvoNetSrcMap.get(source).equals(2)) {
911 logger.debug("Menu Request: Source: {} - Value: {}", source.getNum(), updateData);
912 // For now we only support one level deep menus. If second field is '1', indicates go back to main
914 String[] menuDataSplit = updateData.split(COMMA);
915 if (menuDataSplit.length > 2 && ONE.equals(menuDataSplit[1])) {
917 connector.sendCommand(sourceZone + "MENU0xFFFFFFFF,0,0,0,0,0,0,\"\"");
918 } catch (NuvoException e) {
919 logger.debug("Error sending main menu command to {}", sourceZone);
924 case TYPE_ZONE_CONFIG:
925 logger.debug("Zone Configuration: Zone: {} - Value: {}", zone.getNum(), updateData);
926 // example: BASS1,TREB-2,BALR2,LOUDCMP1
927 Matcher matcher = ZONE_CFG_EQ_PATTERN.matcher(updateData);
928 if (matcher.find()) {
929 updateChannelState(zone, CHANNEL_TYPE_BASS, matcher.group(1));
930 updateChannelState(zone, CHANNEL_TYPE_TREBLE, matcher.group(2));
931 updateChannelState(zone, CHANNEL_TYPE_BALANCE, NuvoStatusCodes.getBalanceFromStr(matcher.group(3)));
932 updateChannelState(zone, CHANNEL_TYPE_LOUDNESS, ONE.equals(matcher.group(4)) ? ON : OFF);
934 matcher = ZONE_CFG_PATTERN.matcher(updateData);
935 // example: ENABLE1,NAME"Great Room",SLAVETO0,GROUP1,SOURCES63,XSRC0,IR1,DND0,LOCKED0,SLAVEEQ0
936 if (matcher.find()) {
937 // TODO: utilize other info such as zone name, available sources bitmask, etc.
939 // if this zone is a member of a group (1-4), add the zone's enum to the appropriate group map
940 if (!ZERO.equals(matcher.group(3))) {
941 nuvoGroupMap.get(matcher.group(3)).add(zone);
944 logger.debug("no match on message: {}", updateData);
948 case TYPE_NN_ALBUM_ART_REQ:
949 // ignore this update unless openHAB is handling this source
950 if (nuvoNetSrcMap.get(source).equals(2)) {
951 logger.debug("Album Art Request for Source: {} - Data: {}", source.getNum(), updateData);
952 // 0x620FD879,80,80,2,0x00C0C0C0,0,0,0,0,1
953 String[] albumArtReq = updateData.split(COMMA);
954 albumArtIds.put(source, Integer.decode(albumArtReq[0]));
957 if (albumArtMap.get(source).length > 1) {
958 connector.sendCommand(source.getId() + ALBUM_ART_AVAILABLE + albumArtIds.get(source) + COMMA
959 + albumArtMap.get(source).length);
961 connector.sendCommand(source.getId() + ALBUM_ART_AVAILABLE + ZERO_COMMA);
963 } catch (NuvoException e) {
964 logger.debug("Error sending ALBUMARTAVAILABLE command for source: {}", source.getNum());
968 case TYPE_NN_ALBUM_ART_FRAG_REQ:
969 // ignore this update unless openHAB is handling this source
970 if (nuvoNetSrcMap.get(source).equals(2)) {
971 logger.debug("Album Art Fragment Request for Source: {} - Data: {}", source.getNum(), updateData);
972 // 0x620FD879,0,750 (id, requested offset from start of image, byte length requested)
973 String[] albumArtFragReq = updateData.split(COMMA);
974 int requestedId = Integer.decode(albumArtFragReq[0]);
975 int offset = Integer.parseInt(albumArtFragReq[1]);
976 int length = Integer.parseInt(albumArtFragReq[2]);
978 if (requestedId == albumArtIds.get(source)) {
979 byte[] chunk = new byte[length];
980 byte[] albumArtBytes = albumArtMap.get(source);
982 if (albumArtBytes != null) {
983 System.arraycopy(albumArtBytes, offset, chunk, 0, length);
984 final String frag = Base64.getEncoder().encodeToString(chunk);
986 connector.sendCommand(source.getId() + ALBUM_ART_FRAG + requestedId + COMMA + offset
987 + COMMA + frag.length() + COMMA + frag);
988 } catch (NuvoException e) {
989 logger.debug("Error sending ALBUMARTFRAG command for source: {}, artId: {}",
990 source.getNum(), requestedId);
996 case TYPE_NN_FAVORITE_REQ:
997 // ignore this update unless openHAB is handling this source
998 if (nuvoNetSrcMap.get(source).equals(2)) {
999 logger.debug("Favorite request for source: {} - favoriteId: {}", source.getNum(), updateData);
1001 int playlistIdx = Integer.parseInt(updateData, 16) - 1000;
1002 updateChannelState(source, CHANNEL_BUTTON_PRESS,
1003 PLAY_MUSIC_PRESET + favoriteMap.get(source)[playlistIdx]);
1004 } catch (NumberFormatException nfe) {
1005 logger.debug("Unable to parse favoriteId: {}", updateData);
1010 logger.debug("onNewMessageEvent: unhandled event type {}", evt.getType());
1011 // Return here because receiving an unknown message does not indicate that one can poll
1015 if (isMps4 && pollStatusNeeded) {
1020 private void loadMenuConfiguration(NuvoThingConfiguration config) {
1021 StringBuilder menuXml = new StringBuilder("<menu>");
1023 if (!config.menuXmlSrc1.isEmpty()) {
1024 menuXml.append("<source>" + config.menuXmlSrc1 + "</source>");
1026 menuXml.append("<source/>");
1028 if (!config.menuXmlSrc2.isEmpty()) {
1029 menuXml.append("<source>" + config.menuXmlSrc2 + "</source>");
1031 menuXml.append("<source/>");
1033 if (!config.menuXmlSrc3.isEmpty()) {
1034 menuXml.append("<source>" + config.menuXmlSrc3 + "</source>");
1036 menuXml.append("<source/>");
1038 if (!config.menuXmlSrc4.isEmpty()) {
1039 menuXml.append("<source>" + config.menuXmlSrc4 + "</source>");
1041 menuXml.append("<source/>");
1043 if (!config.menuXmlSrc5.isEmpty()) {
1044 menuXml.append("<source>" + config.menuXmlSrc5 + "</source>");
1046 menuXml.append("<source/>");
1048 if (!config.menuXmlSrc6.isEmpty()) {
1049 menuXml.append("<source>" + config.menuXmlSrc6 + "</source>");
1051 menuXml.append("<source/>");
1053 menuXml.append("</menu>");
1056 JAXBContext ctx = JAXBUtils.JAXBCONTEXT_NUVO_MENU;
1058 Unmarshaller unmarshaller = ctx.createUnmarshaller();
1059 if (unmarshaller != null) {
1060 XMLStreamReader xsr = JAXBUtils.XMLINPUTFACTORY
1061 .createXMLStreamReader(new StringReader(menuXml.toString()));
1062 NuvoMenu menu = (NuvoMenu) unmarshaller.unmarshal(xsr);
1069 logger.debug("No JAXBContext available to parse Nuvo Menu XML");
1070 } catch (JAXBException | XMLStreamException e) {
1071 logger.warn("Error processing Nuvo Menu XML: {}", e.getLocalizedMessage());
1075 private void enableNuvonet(boolean showReady) {
1076 if (!this.isAnyOhNuvoNet) {
1080 // enable NuvoNet for each source configured as an openHAB NuvoNet source
1081 nuvoNetSrcMap.forEach((source, val) -> {
1082 if (val.equals(2)) {
1084 connector.sendCommand(source.getConfigId() + "NUVONET1");
1085 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1086 } catch (NuvoException | InterruptedException e) {
1087 logger.debug("Error sending SCFG command for source: {}", source.getNum());
1093 // set '1' flag for each source configured as an MPS4 NuvoNet source or openHAB NuvoNet source
1094 connector.sendCommand("SNUMBERS" + nuvoNetSrcMap.get(NuvoEnum.SOURCE1).compareTo(0) + COMMA
1095 + nuvoNetSrcMap.get(NuvoEnum.SOURCE2).compareTo(0) + COMMA
1096 + nuvoNetSrcMap.get(NuvoEnum.SOURCE3).compareTo(0) + COMMA
1097 + nuvoNetSrcMap.get(NuvoEnum.SOURCE4).compareTo(0) + COMMA
1098 + nuvoNetSrcMap.get(NuvoEnum.SOURCE5).compareTo(0) + COMMA
1099 + nuvoNetSrcMap.get(NuvoEnum.SOURCE6).compareTo(0));
1100 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1101 } catch (NuvoException | InterruptedException e) {
1102 logger.debug("Error sending SNUMBERS command");
1105 // go though each source and if is openHAB NuvoNet then configure menu, favorites, etc.
1106 nuvoNetSrcMap.forEach((source, val) -> {
1107 if (val.equals(2)) {
1109 List<TopMenu> topMenuItems = nuvoMenus.getSource().get(source.getNum() - 1).getTopMenu();
1111 if (!topMenuItems.isEmpty()) {
1112 connector.sendCommand(
1113 source.getId() + "MENU," + (topMenuItems.size() < 10 ? topMenuItems.size() : 10));
1114 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1116 for (int i = 0; i < (topMenuItems.size() < 10 ? topMenuItems.size() : 10); i++) {
1117 connector.sendCommand(source.getId() + "MENUITEM" + (i + 1) + ","
1118 + (topMenuItems.get(i).getItems().isEmpty() ? ZERO : ONE) + ",0,\""
1119 + topMenuItems.get(i).getText() + "\"");
1120 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1123 // Build a State options selection that represents this source's custom menu
1124 List<StateOption> sourceMenuStateOptions = new ArrayList<>();
1125 topMenuItems.forEach(topItem -> {
1126 sourceMenuStateOptions.add(new StateOption(topItem.getText(), topItem.getText()));
1127 topItem.getItems().forEach(subItem -> sourceMenuStateOptions
1128 .add(new StateOption(topItem.getText() + "|" + subItem, "-> " + subItem)));
1130 stateDescriptionProvider.setStateOptions(
1131 new ChannelUID(getThing().getUID(),
1132 source.name().toLowerCase() + CHANNEL_DELIMIT + CHANNEL_SOURCE_MENU),
1133 sourceMenuStateOptions);
1136 String[] favorites = favoriteMap.get(source);
1137 if (favorites != null) {
1138 connector.sendCommand(source.getId() + "FAVORITES"
1139 + (favorites.length < 20 ? favorites.length : 20) + COMMA
1140 + (source.getNum() == 1 ? ONE : ZERO) + COMMA + (source.getNum() == 2 ? ONE : ZERO)
1141 + COMMA + (source.getNum() == 3 ? ONE : ZERO) + COMMA
1142 + (source.getNum() == 4 ? ONE : ZERO) + COMMA + (source.getNum() == 5 ? ONE : ZERO)
1143 + COMMA + (source.getNum() == 6 ? ONE : ZERO));
1144 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1146 for (int i = 0; i < (favorites.length < 20 ? favorites.length : 20); i++) {
1147 connector.sendCommand(source.getId() + "FAVORITESITEM" + (i + 1000) + ",0,0,\""
1148 + favPrefixMap.get(source) + favorites[i] + "\"");
1149 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1154 connector.sendCommand(source.getId() + "DISPINFOTWO0,0,0,0,0,0,0");
1155 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1156 connector.sendCommand(source.getId() + "DISPLINES0,0,0,\"Ready\",\"\",\"\",\"\"");
1157 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1160 } catch (NuvoException | InterruptedException e) {
1161 logger.debug("Error configuring NuvoNet for source: {}", source.getNum());
1168 * Schedule the reconnection job
1170 private void scheduleReconnectJob() {
1171 logger.debug("Schedule reconnect job");
1172 cancelReconnectJob();
1173 reconnectJob = scheduler.scheduleWithFixedDelay(() -> {
1174 if (!connector.isConnected()) {
1175 logger.debug("Trying to reconnect...");
1177 if (openConnection()) {
1178 logger.debug("Reconnected");
1179 // Polling status will disconnect from MPS4 on reconnect
1183 enableNuvonet(true);
1185 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Reconnection failed");
1189 }, 1, RECON_POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
1193 * If a ping is not received within ping interval the connection is closed and a reconnect job is scheduled
1195 private void schedulePingTimeoutJob() {
1197 logger.debug("Schedule Ping Timeout job");
1198 cancelPingTimeoutJob();
1199 pingJob = scheduler.schedule(() -> {
1201 scheduleReconnectJob();
1202 }, PING_TIMEOUT_SEC, TimeUnit.SECONDS);
1204 logger.debug("Ping Timeout job not valid for serial connections");
1209 * Cancel the ping timeout job
1211 private void cancelPingTimeoutJob() {
1212 ScheduledFuture<?> pingJob = this.pingJob;
1213 if (pingJob != null) {
1214 pingJob.cancel(true);
1215 this.pingJob = null;
1219 private void pollStatus() {
1220 pollStatusNeeded = false;
1221 scheduler.submit(() -> {
1222 synchronized (sequenceLock) {
1224 connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
1226 NuvoEnum.VALID_SOURCES.forEach(source -> {
1228 connector.sendQuery(source, NuvoCommand.NAME);
1229 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1230 connector.sendQuery(source, NuvoCommand.DISPINFO);
1231 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1232 connector.sendQuery(source, NuvoCommand.DISPLINE);
1233 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1234 } catch (NuvoException | InterruptedException e) {
1235 logger.debug("Error Querying Source data: {}", e.getMessage());
1239 // Query all active zones to get their current status and eq configuration
1240 activeZones.forEach(zoneNum -> {
1242 connector.sendQuery(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.STATUS);
1243 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1244 connector.sendCfgCommand(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.STATUS_QUERY, BLANK);
1245 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1246 connector.sendCfgCommand(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.EQ_QUERY, BLANK);
1247 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1248 } catch (NuvoException | InterruptedException e) {
1249 logger.debug("Error Querying Zone data: {}", e.getMessage());
1253 List<StateOption> sourceStateOptions = new ArrayList<>();
1254 sourceLabels.keySet().forEach(key -> {
1255 sourceStateOptions.add(new StateOption(key, sourceLabels.get(key)));
1258 // Put the source labels on all active zones
1259 activeZones.forEach(zoneNum -> {
1260 stateDescriptionProvider.setStateOptions(
1261 new ChannelUID(getThing().getUID(),
1262 ZONE.toLowerCase() + zoneNum + CHANNEL_DELIMIT + CHANNEL_TYPE_SOURCE),
1263 sourceStateOptions);
1265 } catch (NuvoException e) {
1266 logger.debug("Error polling status from Nuvo: {}", e.getMessage());
1273 * Cancel the reconnection job
1275 private void cancelReconnectJob() {
1276 ScheduledFuture<?> reconnectJob = this.reconnectJob;
1277 if (reconnectJob != null) {
1278 reconnectJob.cancel(true);
1279 this.reconnectJob = null;
1284 * Schedule the polling job
1286 private void schedulePollingJob() {
1290 logger.debug("MPS4 doesn't support polling");
1293 logger.debug("Schedule polling job");
1296 // when the Nuvo amp is off, this will keep the connection (esp Serial over IP) alive and detect if the
1297 // connection goes down
1298 pollingJob = scheduler.scheduleWithFixedDelay(() -> {
1299 if (connector.isConnected()) {
1300 logger.debug("Polling the component for updated status...");
1302 synchronized (sequenceLock) {
1304 connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
1305 } catch (NuvoException e) {
1306 logger.debug("Polling error: {}", e.getMessage());
1309 // if the last event received was more than 1.25 intervals ago,
1310 // the component is not responding even though the connection is still good
1311 if ((System.currentTimeMillis() - lastEventReceived) > (POLLING_INTERVAL_SEC * 1.25 * 1000)) {
1312 logger.debug("Component not responding to status requests");
1313 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1314 "Component not responding to status requests");
1316 scheduleReconnectJob();
1320 }, INITIAL_POLLING_DELAY_SEC, POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
1324 * Cancel the polling job
1326 private void cancelPollingJob() {
1327 ScheduledFuture<?> pollingJob = this.pollingJob;
1328 if (pollingJob != null) {
1329 pollingJob.cancel(true);
1330 this.pollingJob = null;
1335 * Schedule the clock sync job
1337 private void scheduleClockSyncJob() {
1338 logger.debug("Schedule clock sync job");
1339 cancelClockSyncJob();
1340 clockSyncJob = scheduler.scheduleWithFixedDelay(() -> {
1341 if (this.isGConcerto) {
1343 SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy,MM,dd,HH,mm");
1344 connector.sendCommand(NuvoCommand.CFGTIME.getValue() + dateFormat.format(new Date()));
1345 } catch (NuvoException e) {
1346 logger.debug("Error syncing clock: {}", e.getMessage());
1349 this.cancelClockSyncJob();
1351 }, INITIAL_CLOCK_SYNC_DELAY_SEC, CLOCK_SYNC_INTERVAL_SEC, TimeUnit.SECONDS);
1355 * Cancel the clock sync job
1357 private void cancelClockSyncJob() {
1358 ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
1359 if (clockSyncJob != null) {
1360 clockSyncJob.cancel(true);
1361 this.clockSyncJob = null;
1366 * Update the state of a channel (original method signature)
1368 * @param target the channel group
1369 * @param channelType the channel group item
1370 * @param value the value to be updated
1372 private void updateChannelState(NuvoEnum target, String channelType, String value) {
1373 updateChannelState(target, channelType, value, NO_ART);
1377 * Update the state of a channel (overloaded method to handle album_art channel)
1379 * @param target the channel group
1380 * @param channelType the channel group item
1381 * @param value the value to be updated
1382 * @param bytes the byte[] to load into the Image channel
1384 private void updateChannelState(NuvoEnum target, String channelType, String value, byte[] bytes) {
1385 String channel = target.name().toLowerCase() + CHANNEL_DELIMIT + channelType;
1387 if (!isLinked(channel)) {
1391 State state = UnDefType.UNDEF;
1393 if (UNDEF.equals(value)) {
1394 updateState(channel, state);
1398 switch (channelType) {
1399 case CHANNEL_TYPE_POWER:
1400 case CHANNEL_TYPE_MUTE:
1401 case CHANNEL_TYPE_DND:
1402 case CHANNEL_TYPE_PARTY:
1403 case CHANNEL_TYPE_ALLMUTE:
1404 case CHANNEL_TYPE_PAGE:
1405 case CHANNEL_TYPE_LOUDNESS:
1406 state = OnOffType.from(ON.equals(value));
1408 case CHANNEL_TYPE_LOCK:
1409 state = ON.equals(value) ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
1411 case CHANNEL_TYPE_SOURCE:
1412 case CHANNEL_TYPE_TREBLE:
1413 case CHANNEL_TYPE_BASS:
1414 case CHANNEL_TYPE_BALANCE:
1415 state = new DecimalType(value);
1417 case CHANNEL_TYPE_VOLUME:
1418 int volume = Integer.parseInt(value);
1419 long volumePct = Math
1420 .round((double) (MAX_VOLUME - volume) / (double) (MAX_VOLUME - MIN_VOLUME) * 100.0);
1421 state = new PercentType(BigDecimal.valueOf(volumePct));
1423 case CHANNEL_TYPE_BUTTONPRESS:
1424 case CHANNEL_DISPLAY_LINE1:
1425 case CHANNEL_DISPLAY_LINE2:
1426 case CHANNEL_DISPLAY_LINE3:
1427 case CHANNEL_DISPLAY_LINE4:
1428 case CHANNEL_BUTTON_PRESS:
1429 state = new StringType(value);
1431 case CHANNEL_PLAY_MODE:
1432 state = new StringType(NuvoStatusCodes.PLAY_MODE.get(value));
1434 case CHANNEL_TRACK_LENGTH:
1435 case CHANNEL_TRACK_POSITION:
1436 state = new QuantityType<>(Integer.parseInt(value) / 10, NuvoHandler.API_SECOND_UNIT);
1438 case CHANNEL_ALBUM_ART:
1439 state = new RawType(bytes, "image/jpeg");
1444 updateState(channel, state);
1448 * For grouped zones, update the source channel for all group members
1450 * @param zoneEnum the zone where the source was changed
1451 * @param srcId the new source number that was selected
1453 private void updateSrcForZoneGroup(NuvoEnum zoneEnum, String srcId) {
1454 // check if this zone is in a group, if so update the other group member's selected source
1455 nuvoGroupMap.forEach((groupId, groupZones) -> {
1456 if (groupZones.contains(zoneEnum)) {
1457 groupZones.forEach(z -> {
1458 if (!zoneEnum.equals(z)) {
1459 updateChannelState(z, CHANNEL_TYPE_SOURCE, srcId);
1467 * Handle a button press from a UI Player item
1469 * @param target the nuvo zone to receive the command
1470 * @param command the button press command to send to the zone
1472 private void handleControlCommand(NuvoEnum target, Command command) throws NuvoException {
1473 if (command instanceof PlayPauseType) {
1474 connector.sendCommand(target, NuvoCommand.PLAYPAUSE);
1475 } else if (command instanceof NextPreviousType) {
1476 if (command == NextPreviousType.NEXT) {
1477 connector.sendCommand(target, NuvoCommand.NEXT);
1478 } else if (command == NextPreviousType.PREVIOUS) {
1479 connector.sendCommand(target, NuvoCommand.PREV);
1482 logger.warn("Unknown control command: {}", command);
1487 * Scrapes the MPS4's json api to retrieve the currently playing media's album art
1489 * @param source the source that should be queried to load the current album art
1491 private void getMps4AlbumArt(NuvoEnum source) {
1492 final String clientId = UUID.randomUUID().toString();
1494 // try to get cached source instance
1495 String instance = sourceInstanceMap.get(source);
1497 // if not found, need to retrieve from the api, once found these calls will be skipped
1498 if (instance == null) {
1499 // find which zone is using this source
1500 NuvoEnum zone = sourceZoneMap.get(source);
1503 logger.debug("Unable to determine zone that is using source {}", source);
1507 final String json = getMcsJson(String.format(GET_MCS_INSTANCE, mps4Host, zone.getNum(), clientId),
1510 Matcher matcher = MCS_INSTANCE_PATTERN.matcher(json);
1511 if (matcher.find()) {
1512 instance = matcher.group(1);
1513 sourceInstanceMap.put(source, instance);
1514 logger.debug("Found instance '{}' for source {}", instance, source);
1516 logger.debug("No instance match found for json: {}", json);
1519 } catch (TimeoutException | ExecutionException e) {
1520 logger.debug("Failed getting instance name", e);
1522 } catch (InterruptedException e) {
1523 logger.debug("InterruptedException getting instance name", e);
1524 Thread.currentThread().interrupt();
1531 logger.debug("Using MCS instance '{}' for source {}", instance, source);
1532 final String json = getMcsJson(String.format(GET_MCS_STATUS, mps4Host, instance, clientId), clientId);
1534 if (json.contains("\"name\":\"PlayState\",\"value\":3}")) {
1535 Matcher matcher = ART_GUID_PATTERN.matcher(json);
1536 if (matcher.find()) {
1537 final String nowPlayingGuid = matcher.group(1);
1539 // If streaming (NowPlayingType=Radio), always retrieve because the same NowPlayingGuid can
1540 // get a different image written to it by Gracenote when the track changes
1541 if (!mps4ArtGuids.get(source).equals(nowPlayingGuid)
1542 || json.contains("NowPlayingType\",\"value\":\"Radio\"}")) {
1543 ContentResponse artResponse = httpClient
1544 .newRequest(String.format(GET_MCS_ART, mps4Host, nowPlayingGuid, instance)).method(GET)
1545 .timeout(10, TimeUnit.SECONDS).send();
1547 if (artResponse.getStatus() == OK_200) {
1548 logger.debug("Retrieved album art for guid: {}", nowPlayingGuid);
1549 updateChannelState(source, CHANNEL_ALBUM_ART, BLANK, artResponse.getContent());
1550 mps4ArtGuids.put(source, nowPlayingGuid);
1553 logger.debug("Album art has not changed, guid: {}", nowPlayingGuid);
1556 logger.debug("NowPlayingGuid not found");
1559 logger.debug("PlayState not valid");
1561 } catch (TimeoutException | ExecutionException e) {
1562 logger.debug("Failed getting album art", e);
1563 updateChannelState(source, CHANNEL_ALBUM_ART, UNDEF);
1564 mps4ArtGuids.put(source, BLANK);
1565 } catch (InterruptedException e) {
1566 logger.debug("InterruptedException getting album art", e);
1567 updateChannelState(source, CHANNEL_ALBUM_ART, UNDEF);
1568 mps4ArtGuids.put(source, BLANK);
1569 Thread.currentThread().interrupt();
1574 * Used by getMps4AlbumArt to abstract retrieval of status json from MCS
1576 * @param commandUrl the url with the embedded commands to send to MCS
1577 * @param clientId the current clientId
1578 * @return string json result from the command executed
1580 * @throws InterruptedException
1581 * @throws TimeoutException
1582 * @throws ExecutionException
1584 private String getMcsJson(String commandUrl, String clientId)
1585 throws InterruptedException, TimeoutException, ExecutionException {
1586 ContentResponse commandResp = httpClient.newRequest(commandUrl).method(GET).timeout(10, TimeUnit.SECONDS)
1589 if (commandResp.getStatus() == OK_200) {
1590 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1591 ContentResponse jsonResp = httpClient.newRequest(String.format(GET_MCS_JSON, mps4Host, clientId))
1592 .method(GET).timeout(10, TimeUnit.SECONDS).send();
1593 if (jsonResp.getStatus() == OK_200) {
1594 return jsonResp.getContentAsString();
1596 logger.debug("Got error response {} when getting json from MCS", commandResp.getStatus());
1600 logger.debug("Got error response {} when sending json command url: {}", commandResp.getStatus(), commandUrl);