2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.nuvo.internal.handler;
15 import static org.eclipse.jetty.http.HttpMethod.GET;
16 import static org.eclipse.jetty.http.HttpStatus.OK_200;
17 import static org.openhab.binding.nuvo.internal.NuvoBindingConstants.*;
19 import java.io.StringReader;
20 import java.math.BigDecimal;
21 import java.text.SimpleDateFormat;
22 import java.util.ArrayList;
23 import java.util.Base64;
24 import java.util.Collection;
25 import java.util.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.concurrent.ExecutionException;
32 import java.util.concurrent.ScheduledFuture;
33 import java.util.concurrent.TimeUnit;
34 import java.util.concurrent.TimeoutException;
35 import java.util.regex.Matcher;
36 import java.util.regex.Pattern;
37 import java.util.stream.Collectors;
38 import java.util.stream.IntStream;
40 import javax.measure.Unit;
41 import javax.measure.quantity.Time;
42 import javax.xml.bind.JAXBContext;
43 import javax.xml.bind.JAXBException;
44 import javax.xml.bind.Unmarshaller;
45 import javax.xml.stream.XMLStreamException;
46 import javax.xml.stream.XMLStreamReader;
48 import org.eclipse.jdt.annotation.NonNullByDefault;
49 import org.eclipse.jdt.annotation.Nullable;
50 import org.eclipse.jetty.client.HttpClient;
51 import org.eclipse.jetty.client.api.ContentResponse;
52 import org.openhab.binding.nuvo.internal.NuvoException;
53 import org.openhab.binding.nuvo.internal.NuvoStateDescriptionOptionProvider;
54 import org.openhab.binding.nuvo.internal.NuvoThingActions;
55 import org.openhab.binding.nuvo.internal.communication.NuvoCommand;
56 import org.openhab.binding.nuvo.internal.communication.NuvoConnector;
57 import org.openhab.binding.nuvo.internal.communication.NuvoDefaultConnector;
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(.*)$");
140 private final Logger logger = LoggerFactory.getLogger(NuvoHandler.class);
141 private final NuvoStateDescriptionOptionProvider stateDescriptionProvider;
142 private final SerialPortManager serialPortManager;
143 private final HttpClient httpClient;
145 private @Nullable ScheduledFuture<?> reconnectJob;
146 private @Nullable ScheduledFuture<?> pollingJob;
147 private @Nullable ScheduledFuture<?> clockSyncJob;
148 private @Nullable ScheduledFuture<?> pingJob;
150 private NuvoConnector connector = new NuvoDefaultConnector();
151 private long lastEventReceived = System.currentTimeMillis();
152 private int numZones = 1;
153 private String versionString = BLANK;
154 private boolean isGConcerto = false;
155 private Object sequenceLock = new Object();
157 private boolean isAnyOhNuvoNet = false;
158 private NuvoMenu nuvoMenus = new NuvoMenu();
159 private HashMap<String, Set<NuvoEnum>> nuvoGroupMap = new HashMap<String, Set<NuvoEnum>>();
160 private HashMap<NuvoEnum, Integer> nuvoNetSrcMap = new HashMap<NuvoEnum, Integer>();
161 private HashMap<NuvoEnum, String> favPrefixMap = new HashMap<NuvoEnum, String>();
162 private HashMap<NuvoEnum, String[]> favoriteMap = new HashMap<NuvoEnum, String[]>();
164 private HashMap<NuvoEnum, byte[]> albumArtMap = new HashMap<NuvoEnum, byte[]>();
165 private HashMap<NuvoEnum, Integer> albumArtIds = new HashMap<NuvoEnum, Integer>();
166 private HashMap<NuvoEnum, String> dispInfoCache = new HashMap<NuvoEnum, String>();
168 Set<Integer> activeZones = new HashSet<>(1);
170 // A tree map that maps the source ids to source labels
171 TreeMap<String, String> sourceLabels = new TreeMap<String, String>();
173 // Indicates if there is a need to poll status because of a disconnection used for MPS4 systems
174 boolean pollStatusNeeded = true;
175 boolean isMps4 = false;
180 public NuvoHandler(Thing thing, NuvoStateDescriptionOptionProvider stateDescriptionProvider,
181 SerialPortManager serialPortManager, HttpClient httpClient) {
183 this.stateDescriptionProvider = stateDescriptionProvider;
184 this.serialPortManager = serialPortManager;
185 this.httpClient = httpClient;
189 public void initialize() {
190 final String uid = this.getThing().getUID().getAsString();
191 NuvoThingConfiguration config = getConfigAs(NuvoThingConfiguration.class);
192 final String serialPort = config.serialPort;
193 final String host = config.host;
194 final Integer port = config.port;
195 final Integer numZones = config.numZones;
197 // Check configuration settings
198 String configError = null;
199 if ((serialPort == null || serialPort.isEmpty()) && (host == null || host.isEmpty())) {
200 configError = "undefined serialPort and host configuration settings; please set one of them";
201 } else if (serialPort != null && (host == null || host.isEmpty())) {
202 if (serialPort.toLowerCase().startsWith("rfc2217")) {
203 configError = "use host and port configuration settings for a serial over IP connection";
207 configError = "undefined port configuration setting";
208 } else if (port <= 0) {
209 configError = "invalid port configuration setting";
213 if (configError != null) {
214 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configError);
218 if (serialPort != null && !serialPort.isEmpty()) {
219 connector = new NuvoSerialConnector(serialPortManager, serialPort, uid);
220 } else if (host != null && port != null) {
221 connector = new NuvoIpConnector(host, port, uid);
222 this.isMps4 = (port.intValue() == MPS4_PORT);
224 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
225 "Either Serial port or Host & Port must be specifed");
229 nuvoNetSrcMap.put(NuvoEnum.SOURCE1, config.nuvoNetSrc1);
230 nuvoNetSrcMap.put(NuvoEnum.SOURCE2, config.nuvoNetSrc2);
231 nuvoNetSrcMap.put(NuvoEnum.SOURCE3, config.nuvoNetSrc3);
232 nuvoNetSrcMap.put(NuvoEnum.SOURCE4, config.nuvoNetSrc4);
233 nuvoNetSrcMap.put(NuvoEnum.SOURCE5, config.nuvoNetSrc5);
234 nuvoNetSrcMap.put(NuvoEnum.SOURCE6, config.nuvoNetSrc6);
236 nuvoGroupMap.put("1", new HashSet<NuvoEnum>());
237 nuvoGroupMap.put("2", new HashSet<NuvoEnum>());
238 nuvoGroupMap.put("3", new HashSet<NuvoEnum>());
239 nuvoGroupMap.put("4", new HashSet<NuvoEnum>());
242 logger.debug("Port set to {} configuring binding for MPS4 compatability", MPS4_PORT);
244 this.isAnyOhNuvoNet = (config.nuvoNetSrc1.equals(2) || config.nuvoNetSrc2.equals(2)
245 || config.nuvoNetSrc3.equals(2) || config.nuvoNetSrc4.equals(2) || config.nuvoNetSrc5.equals(2)
246 || config.nuvoNetSrc6.equals(2));
248 if (this.isAnyOhNuvoNet) {
249 logger.debug("At least one source is configured as an openHAB NuvoNet source");
250 connector.setAnyOhNuvoNet(true);
251 loadMenuConfiguration(config);
253 favoriteMap.put(NuvoEnum.SOURCE1,
254 !config.favoritesSrc1.isEmpty() ? config.favoritesSrc1.split(COMMA) : new String[0]);
255 favoriteMap.put(NuvoEnum.SOURCE2,
256 !config.favoritesSrc2.isEmpty() ? config.favoritesSrc2.split(COMMA) : new String[0]);
257 favoriteMap.put(NuvoEnum.SOURCE3,
258 !config.favoritesSrc3.isEmpty() ? config.favoritesSrc3.split(COMMA) : new String[0]);
259 favoriteMap.put(NuvoEnum.SOURCE4,
260 !config.favoritesSrc4.isEmpty() ? config.favoritesSrc4.split(COMMA) : new String[0]);
261 favoriteMap.put(NuvoEnum.SOURCE5,
262 !config.favoritesSrc5.isEmpty() ? config.favoritesSrc5.split(COMMA) : new String[0]);
263 favoriteMap.put(NuvoEnum.SOURCE6,
264 !config.favoritesSrc6.isEmpty() ? config.favoritesSrc6.split(COMMA) : new String[0]);
266 favPrefixMap.put(NuvoEnum.SOURCE1, config.favPrefix1);
267 favPrefixMap.put(NuvoEnum.SOURCE2, config.favPrefix2);
268 favPrefixMap.put(NuvoEnum.SOURCE3, config.favPrefix3);
269 favPrefixMap.put(NuvoEnum.SOURCE4, config.favPrefix4);
270 favPrefixMap.put(NuvoEnum.SOURCE5, config.favPrefix5);
271 favPrefixMap.put(NuvoEnum.SOURCE6, config.favPrefix6);
273 albumArtIds.put(NuvoEnum.SOURCE1, 0);
274 albumArtIds.put(NuvoEnum.SOURCE2, 0);
275 albumArtIds.put(NuvoEnum.SOURCE3, 0);
276 albumArtIds.put(NuvoEnum.SOURCE4, 0);
277 albumArtIds.put(NuvoEnum.SOURCE5, 0);
278 albumArtIds.put(NuvoEnum.SOURCE6, 0);
280 albumArtMap.put(NuvoEnum.SOURCE1, NO_ART);
281 albumArtMap.put(NuvoEnum.SOURCE2, NO_ART);
282 albumArtMap.put(NuvoEnum.SOURCE3, NO_ART);
283 albumArtMap.put(NuvoEnum.SOURCE4, NO_ART);
284 albumArtMap.put(NuvoEnum.SOURCE5, NO_ART);
285 albumArtMap.put(NuvoEnum.SOURCE6, NO_ART);
289 if (numZones != null) {
290 this.numZones = numZones;
293 activeZones = IntStream.range((1), (this.numZones + 1)).boxed().collect(Collectors.toSet());
295 // remove the channels for the zones we are not using
296 if (this.numZones < MAX_ZONES) {
297 List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
299 List<Integer> zonesToRemove = IntStream.range((this.numZones + 1), (MAX_ZONES + 1)).boxed()
300 .collect(Collectors.toList());
302 zonesToRemove.forEach(zone -> channels.removeIf(c -> (c.getUID().getId().contains("zone" + zone))));
303 updateThing(editThing().withChannels(channels).build());
306 // Build a list of State options for the global favorites using user config values (if supplied)
307 String[] favoritesArr = !config.favoriteLabels.isEmpty() ? config.favoriteLabels.split(COMMA) : new String[0];
308 List<StateOption> favoriteLabelsStateOptions = new ArrayList<>();
309 for (int i = 0; i < MAX_FAV; i++) {
310 if (favoritesArr.length > i) {
311 favoriteLabelsStateOptions.add(new StateOption(String.valueOf(i + 1), favoritesArr[i]));
312 } else if (favoritesArr.length == 0) {
313 favoriteLabelsStateOptions.add(new StateOption(String.valueOf(i + 1), "Favorite " + (i + 1)));
317 // Also add any openHAB NuvoNet source favorites to the list
318 for (int src = 1; src <= MAX_SRC; src++) {
319 NuvoEnum source = NuvoEnum.valueOf(SOURCE + src);
320 String[] favorites = favoriteMap.get(source);
321 if (favorites != null) {
322 for (int fav = 0; fav < favorites.length; fav++) {
323 favoriteLabelsStateOptions.add(new StateOption(String.valueOf(src * 100 + fav),
324 favPrefixMap.get(source) + favorites[fav]));
329 // Put the global favorites labels on all active zones
330 activeZones.forEach(zoneNum -> {
331 stateDescriptionProvider.setStateOptions(
332 new ChannelUID(getThing().getUID(),
333 ZONE.toLowerCase() + zoneNum + CHANNEL_DELIMIT + CHANNEL_TYPE_FAVORITE),
334 favoriteLabelsStateOptions);
337 if (config.clockSync) {
338 scheduleClockSyncJob();
341 scheduleReconnectJob();
342 schedulePollingJob();
343 schedulePingTimeoutJob();
344 updateStatus(ThingStatus.UNKNOWN);
348 public void dispose() {
349 if (this.isAnyOhNuvoNet) {
351 // disable NuvoNet for each source that was configured as an openHAB NuvoNet source
352 nuvoNetSrcMap.forEach((source, val) -> {
355 connector.sendCommand(source.getId() + "DISPINFOTWO0,0,0,0,0,0,0");
356 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
357 connector.sendCommand(
358 source.getId() + "DISPLINES0,0,0,\"Source Unavailable\",\"\",\"\",\"\"");
359 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
360 connector.sendCommand(source.getConfigId() + "NUVONET0");
361 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
362 } catch (NuvoException | InterruptedException e) {
363 logger.debug("Error sending command to disable NuvoNet source: {}", source.getNum());
368 // need '1' flag for sources configured as an MPS4 NuvoNet source, but disable openHAB NuvoNet sources
369 connector.sendCommand("SNUMBERS" + (nuvoNetSrcMap.get(NuvoEnum.SOURCE1).equals(1) ? ONE : ZERO) + COMMA
370 + (nuvoNetSrcMap.get(NuvoEnum.SOURCE2).equals(1) ? ONE : ZERO) + COMMA
371 + (nuvoNetSrcMap.get(NuvoEnum.SOURCE3).equals(1) ? ONE : ZERO) + COMMA
372 + (nuvoNetSrcMap.get(NuvoEnum.SOURCE4).equals(1) ? ONE : ZERO) + COMMA
373 + (nuvoNetSrcMap.get(NuvoEnum.SOURCE5).equals(1) ? ONE : ZERO) + COMMA
374 + (nuvoNetSrcMap.get(NuvoEnum.SOURCE6).equals(1) ? ONE : ZERO));
375 } catch (NuvoException e) {
376 logger.debug("Error sending SNUMBERS command to disable NuvoNet sources");
380 cancelReconnectJob();
382 cancelClockSyncJob();
383 cancelPingTimeoutJob();
389 public Collection<Class<? extends ThingHandlerService>> getServices() {
390 return List.of(NuvoThingActions.class);
393 public void handleRawCommand(String command) {
394 synchronized (sequenceLock) {
396 connector.sendCommand(command);
397 } catch (NuvoException e) {
398 logger.warn("Nuvo Command: {} failed", command);
404 * Handle a command from the UI
406 * @param channelUID the channel sending the command
407 * @param command the command received
411 public void handleCommand(ChannelUID channelUID, Command command) {
412 String channel = channelUID.getId();
413 String[] channelSplit = channel.split(CHANNEL_DELIMIT);
414 NuvoEnum target = NuvoEnum.valueOf(channelSplit[0].toUpperCase());
416 String channelType = channelSplit[1];
418 if (getThing().getStatus() != ThingStatus.ONLINE) {
419 logger.debug("Thing is not ONLINE; command {} from channel {} is ignored", command, channel);
423 synchronized (sequenceLock) {
424 if (!connector.isConnected()) {
425 logger.warn("Command {} from channel {} is ignored: connection not established", command, channel);
430 switch (channelType) {
431 case CHANNEL_TYPE_POWER:
432 if (command instanceof OnOffType) {
433 connector.sendCommand(target, command == OnOffType.ON ? NuvoCommand.ON : NuvoCommand.OFF);
436 case CHANNEL_TYPE_SOURCE:
437 if (command instanceof DecimalType decimalCommand) {
438 int value = decimalCommand.intValue();
439 if (value >= 1 && value <= MAX_SRC) {
440 logger.debug("Got source command {} zone {}", value, target);
441 connector.sendCommand(target, NuvoCommand.SOURCE, String.valueOf(value));
443 // update the other group member's selected source
444 updateSrcForZoneGroup(target, String.valueOf(value));
448 case CHANNEL_TYPE_FAVORITE:
449 if (command instanceof DecimalType decimalCommand) {
450 int value = decimalCommand.intValue();
451 if (value >= 1 && value <= MAX_FAV) {
452 logger.debug("Got favorite command {} zone {}", value, target);
453 connector.sendCommand(target, NuvoCommand.FAVORITE, String.valueOf(value));
454 } else if (value >= 100 && value <= 650) {
455 String sourceNum = String.valueOf(value / 100);
456 NuvoEnum source = NuvoEnum.valueOf(SOURCE + sourceNum);
457 updateChannelState(source, CHANNEL_BUTTON_PRESS,
458 PLAY_MUSIC_PRESET + favoriteMap.get(source)[value % 100]);
459 connector.sendCommand(target, NuvoCommand.SOURCE, sourceNum);
461 // if this zone is in a group, update the other group member's selected source
462 updateSrcForZoneGroup(target, sourceNum);
466 case CHANNEL_TYPE_VOLUME:
467 if (command instanceof PercentType percentCommand) {
468 int value = (MAX_VOLUME
469 - (int) Math.round(percentCommand.doubleValue() / 100.0 * (MAX_VOLUME - MIN_VOLUME))
471 logger.debug("Got volume command {} zone {}", value, target);
472 connector.sendCommand(target, NuvoCommand.VOLUME, String.valueOf(value));
475 case CHANNEL_TYPE_MUTE:
476 if (command instanceof OnOffType) {
477 connector.sendCommand(target,
478 command == OnOffType.ON ? NuvoCommand.MUTE_ON : NuvoCommand.MUTE_OFF);
481 case CHANNEL_TYPE_TREBLE:
482 if (command instanceof DecimalType decimalCommand) {
483 int value = decimalCommand.intValue();
484 if (value >= MIN_EQ && value <= MAX_EQ) {
485 // device can only accept even values
486 if (value % 2 == 1) {
489 logger.debug("Got treble command {} zone {}", value, target);
490 connector.sendCfgCommand(target, NuvoCommand.TREBLE, String.valueOf(value));
494 case CHANNEL_TYPE_BASS:
495 if (command instanceof DecimalType decimalCommand) {
496 int value = decimalCommand.intValue();
497 if (value >= MIN_EQ && value <= MAX_EQ) {
498 if (value % 2 == 1) {
501 logger.debug("Got bass command {} zone {}", value, target);
502 connector.sendCfgCommand(target, NuvoCommand.BASS, String.valueOf(value));
506 case CHANNEL_TYPE_BALANCE:
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 balance command {} zone {}", value, target);
514 connector.sendCfgCommand(target, NuvoCommand.BALANCE,
515 NuvoStatusCodes.getBalanceFromInt(value));
519 case CHANNEL_TYPE_LOUDNESS:
520 if (command instanceof OnOffType) {
521 connector.sendCfgCommand(target, NuvoCommand.LOUDNESS,
522 command == OnOffType.ON ? ONE : ZERO);
525 case CHANNEL_TYPE_CONTROL:
526 handleControlCommand(target, command);
528 case CHANNEL_TYPE_DND:
529 if (command instanceof OnOffType) {
530 connector.sendCommand(target,
531 command == OnOffType.ON ? NuvoCommand.DND_ON : NuvoCommand.DND_OFF);
534 case CHANNEL_TYPE_PARTY:
535 if (command instanceof OnOffType) {
536 connector.sendCommand(target,
537 command == OnOffType.ON ? NuvoCommand.PARTY_ON : NuvoCommand.PARTY_OFF);
540 case CHANNEL_DISPLAY_LINE1:
541 if (command instanceof StringType) {
542 connector.sendCommand(target, NuvoCommand.DISPLINE1, "\"" + command + "\"");
545 case CHANNEL_DISPLAY_LINE2:
546 if (command instanceof StringType) {
547 connector.sendCommand(target, NuvoCommand.DISPLINE2, "\"" + command + "\"");
550 case CHANNEL_DISPLAY_LINE3:
551 if (command instanceof StringType) {
552 connector.sendCommand(target, NuvoCommand.DISPLINE3, "\"" + command + "\"");
555 case CHANNEL_DISPLAY_LINE4:
556 if (command instanceof StringType) {
557 connector.sendCommand(target, NuvoCommand.DISPLINE4, "\"" + command + "\"");
560 case CHANNEL_TYPE_ALLOFF:
561 if (command instanceof OnOffType) {
562 connector.sendCommand(NuvoCommand.ALLOFF);
565 case CHANNEL_TYPE_ALLMUTE:
566 if (command instanceof OnOffType) {
567 connector.sendCommand(
568 command == OnOffType.ON ? NuvoCommand.ALLMUTE_ON : NuvoCommand.ALLMUTE_OFF);
571 case CHANNEL_TYPE_PAGE:
572 if (command instanceof OnOffType) {
573 connector.sendCommand(command == OnOffType.ON ? NuvoCommand.PAGE_ON : NuvoCommand.PAGE_OFF);
576 case CHANNEL_TYPE_SENDCMD:
577 if (command instanceof StringType) {
578 String commandStr = command.toString();
579 if (commandStr.contains(DISP_INFO_TWO)) {
580 NuvoEnum source = NuvoEnum
581 .valueOf(commandStr.split(DISP_INFO_TWO)[0].replace("S", SOURCE));
582 dispInfoCache.put(source, commandStr);
584 // if 'albumartid' is present, substitute it with the albumArtId hex string
585 connector.sendCommand(commandStr.replace(ALBUM_ART_ID,
586 (OFFSET_ZERO + Integer.toHexString(albumArtIds.get(source)))));
588 connector.sendCommand(commandStr);
592 case CHANNEL_ART_URL:
593 if (command instanceof StringType) {
594 String url = command.toString();
595 if (url.startsWith(HTTP) || url.startsWith(HTTPS)) {
597 ContentResponse contentResponse = httpClient.newRequest(url).method(GET)
598 .timeout(10, TimeUnit.SECONDS).send();
599 int httpStatus = contentResponse.getStatus();
600 if (httpStatus == OK_200) {
601 albumArtMap.put(target,
602 NuvoImageResizer.resizeImage(contentResponse.getContent(), 80, 80));
604 updateChannelState(target, CHANNEL_ALBUM_ART, BLANK,
605 contentResponse.getContent());
607 albumArtMap.put(target, NO_ART);
608 albumArtIds.put(target, 0);
609 updateChannelState(target, CHANNEL_ALBUM_ART, UNDEF);
612 } catch (InterruptedException | TimeoutException | ExecutionException e) {
613 albumArtMap.put(target, NO_ART);
614 albumArtIds.put(target, 0);
615 updateChannelState(target, CHANNEL_ALBUM_ART, UNDEF);
618 albumArtIds.put(target, Math.abs(url.hashCode()));
620 // re-send the cached DISPINFOTWO message, substituting in the new albumArtId
621 if (dispInfoCache.get(target) != null) {
622 connector.sendCommand(dispInfoCache.get(target).replace(ALBUM_ART_ID,
623 (OFFSET_ZERO + Integer.toHexString(albumArtIds.get(target)))));
626 albumArtMap.put(target, NO_ART);
627 albumArtIds.put(target, 0);
628 updateChannelState(target, CHANNEL_ALBUM_ART, UNDEF);
632 } catch (NuvoException e) {
633 logger.warn("Command {} from channel {} failed: {}", command, channel, e.getMessage());
634 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Sending command failed");
636 scheduleReconnectJob();
642 * Open the connection with the Nuvo device
644 * @return true if the connection is opened successfully or false if not
646 private synchronized boolean openConnection() {
647 connector.addEventListener(this);
650 } catch (NuvoException e) {
651 logger.debug("openConnection() failed: {}", e.getMessage());
653 logger.debug("openConnection(): {}", connector.isConnected() ? "connected" : "disconnected");
654 return connector.isConnected();
658 * Close the connection with the Nuvo device
660 private synchronized void closeConnection() {
661 if (connector.isConnected()) {
663 connector.removeEventListener(this);
664 pollStatusNeeded = true;
665 logger.debug("closeConnection(): disconnected");
670 * Handle an event received from the Nuvo device
672 * @param evt the event to process
675 public void onNewMessageEvent(NuvoMessageEvent evt) {
676 logger.debug("onNewMessageEvent: zone {}, source {}, value {}", evt.getZone(), evt.getSrc(), evt.getValue());
677 lastEventReceived = System.currentTimeMillis();
679 final NuvoEnum zone = !evt.getZone().isEmpty() ? NuvoEnum.valueOf(ZONE + evt.getZone()) : NuvoEnum.SYSTEM;
680 final NuvoEnum source = !evt.getSrc().isEmpty() ? NuvoEnum.valueOf(SOURCE + evt.getSrc()) : NuvoEnum.SYSTEM;
681 final String sourceZone = source.getId() + zone.getId();
682 final String updateData = evt.getValue().trim();
684 if (this.getThing().getStatus() != ThingStatus.ONLINE) {
685 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, this.versionString);
688 switch (evt.getType()) {
690 this.versionString = updateData;
691 // Determine if we are a Grand Concerto or not
692 if (this.versionString.contains(GC_STR)) {
693 logger.debug("Grand Concerto detected");
694 this.isGConcerto = true;
695 connector.setEssentia(false);
697 logger.debug("Grand Concerto not detected");
701 logger.debug("Restart message received; re-sending initialization messages");
702 enableNuvonet(false);
705 logger.debug("Ping message received- rescheduling ping timeout");
706 schedulePingTimeoutJob();
707 // Return here because receiving a ping does not indicate that one can poll
710 activeZones.forEach(zoneNum -> {
711 updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_POWER, OFF);
714 // Publish the ALLOFF event to all button channels for awareness in source rules
715 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, ZERO + COMMA + ALLOFF);
716 NuvoEnum.VALID_SOURCES.forEach(src -> {
717 updateChannelState(src, CHANNEL_BUTTON_PRESS, ALLOFF);
722 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_ALLMUTE, ONE.equals(updateData) ? ON : OFF);
723 activeZones.forEach(zoneNum -> {
724 updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_MUTE,
725 ONE.equals(updateData) ? ON : OFF);
729 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_PAGE, ONE.equals(updateData) ? ON : OFF);
731 case TYPE_SOURCE_UPDATE:
732 logger.debug("Source update: Source: {} - Value: {}", source.getNum(), updateData);
734 if (updateData.contains(DISPLINE)) {
735 // example: DISPLINE2,"Play My Song (Featuring Dee Ajayi)"
736 Matcher matcher = DISP_PATTERN.matcher(updateData);
737 if (matcher.find()) {
738 updateChannelState(source, CHANNEL_DISPLAY_LINE + matcher.group(1), matcher.group(2));
740 logger.debug("no match on message: {}", updateData);
742 } else if (updateData.contains(DISPINFO)) {
743 // example: DISPINFO,DUR0,POS70,STATUS2 (DUR and POS are expressed in tenths of a second)
744 // 6 places(tenths of a second)-> max 999,999 /10/60/60/24 = 1.15 days
745 Matcher matcher = DISP_INFO_PATTERN.matcher(updateData);
746 if (matcher.find()) {
747 updateChannelState(source, CHANNEL_TRACK_LENGTH, matcher.group(1));
748 updateChannelState(source, CHANNEL_TRACK_POSITION, matcher.group(2));
749 updateChannelState(source, CHANNEL_PLAY_MODE, matcher.group(3));
751 logger.debug("no match on message: {}", updateData);
753 } else if (updateData.contains(NAME_QUOTE)) {
754 // example: NAME"Ipod"
755 String name = updateData.split("\"")[1];
756 sourceLabels.put(String.valueOf(source.getNum()), name);
759 case TYPE_ZONE_UPDATE:
760 logger.debug("Zone update: Zone: {} - Value: {}", zone.getNum(), updateData);
762 // or: ON,SRC3,VOL63,DND0,LOCK0
763 // or: ON,SRC3,MUTE,DND0,LOCK0
765 if (OFF.equals(updateData)) {
766 updateChannelState(zone, CHANNEL_TYPE_POWER, OFF);
767 updateChannelState(zone, CHANNEL_TYPE_SOURCE, UNDEF);
769 Matcher matcher = ZONE_PATTERN.matcher(updateData);
770 if (matcher.find()) {
771 updateChannelState(zone, CHANNEL_TYPE_POWER, ON);
772 updateChannelState(zone, CHANNEL_TYPE_SOURCE, matcher.group(1));
774 // update the other group member's selected source
775 updateSrcForZoneGroup(zone, matcher.group(1));
777 if (MUTE.equals(matcher.group(2))) {
778 updateChannelState(zone, CHANNEL_TYPE_MUTE, ON);
780 updateChannelState(zone, CHANNEL_TYPE_MUTE, NuvoCommand.OFF.getValue());
781 updateChannelState(zone, CHANNEL_TYPE_VOLUME, matcher.group(2).replace(VOL, BLANK));
784 updateChannelState(zone, CHANNEL_TYPE_DND, ONE.equals(matcher.group(3)) ? ON : OFF);
785 updateChannelState(zone, CHANNEL_TYPE_LOCK, ONE.equals(matcher.group(4)) ? ON : OFF);
787 logger.debug("no match on message: {}", updateData);
791 case TYPE_ZONE_SOURCE_BUTTON:
792 logger.debug("Source Button pressed: Source: {} - Button: {}", source.getNum(), updateData);
793 updateChannelState(source, CHANNEL_BUTTON_PRESS, updateData);
794 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, zone.getNum() + COMMA + updateData);
797 String buttonAction = NuvoStatusCodes.BUTTON_CODE.get(updateData);
799 if (buttonAction != null) {
800 logger.debug("NuvoNet Source Button pressed: Source: {} - Button: {}", source.getNum(),
802 updateChannelState(source, CHANNEL_BUTTON_PRESS, buttonAction);
803 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, zone.getNum() + COMMA + buttonAction);
805 logger.debug("NuvoNet Source Button pressed: Source: {} - Unknown button code: {}", source.getNum(),
807 updateChannelState(source, CHANNEL_BUTTON_PRESS, updateData);
808 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS, zone.getNum() + COMMA + updateData);
811 case TYPE_NN_MENU_ITEM_SELECTED:
812 // ignore this update unless openHAB is handling this source
813 if (nuvoNetSrcMap.get(source).equals(2)) {
814 String[] updateDataSplit = updateData.split(COMMA);
815 String menuId = updateDataSplit[0];
816 int menuItemIdx = Integer.parseInt(updateDataSplit[1]) - 1;
818 boolean exitMenu = false;
819 if ("0xFFFFFFFF".equals(menuId)) {
820 TopMenu topMenuItem = nuvoMenus.getSource().get(source.getNum() - 1).getTopMenu()
822 logger.debug("Top Menu item selected: Source: {} - Menu Item: {}", source.getNum(),
823 topMenuItem.getText());
824 updateChannelState(source, CHANNEL_BUTTON_PRESS, topMenuItem.getText());
825 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS,
826 zone.getNum() + COMMA + topMenuItem.getText());
828 List<String> subMenuItems = topMenuItem.getItems();
830 if (subMenuItems.isEmpty()) {
833 // send submenu (maximum of 20 items)
834 int subMenuSize = subMenuItems.size() < 20 ? subMenuItems.size() : 20;
836 connector.sendCommand(sourceZone + "MENU" + (menuItemIdx + 11) + ",0,0," + subMenuSize
837 + ",0,0," + subMenuSize + ",\"" + topMenuItem.getText() + "\"");
838 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
840 for (int i = 0; i < subMenuSize; i++) {
841 connector.sendCommand(
842 sourceZone + "MENUITEM" + (i + 1) + ",0,0,\"" + subMenuItems.get(i) + "\"");
844 } catch (NuvoException | InterruptedException e) {
845 logger.debug("Error sending sub menu to {}", sourceZone);
849 // a sub menu item was selected
850 TopMenu topMenuItem = nuvoMenus.getSource().get(source.getNum() - 1).getTopMenu()
851 .get(Integer.decode(menuId) - 11);
852 String subMenuItem = topMenuItem.getItems().get(menuItemIdx);
854 logger.debug("Sub Menu item selected: Source: {} - Menu Item: {}", source.getNum(),
855 topMenuItem.getText() + "|" + subMenuItem);
856 updateChannelState(source, CHANNEL_BUTTON_PRESS, topMenuItem.getText() + "|" + subMenuItem);
857 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_BUTTONPRESS,
858 zone.getNum() + COMMA + topMenuItem.getText() + "|" + subMenuItem);
864 // tell the zone to exit the menu
865 connector.sendCommand(sourceZone + "MENU0,0,0,0,0,0,0,\"\"");
866 } catch (NuvoException e) {
867 logger.debug("Error sending exit menu command to {}", sourceZone);
872 case TYPE_NN_MENUREQ:
873 // ignore this update unless openHAB is handling this source
874 if (nuvoNetSrcMap.get(source).equals(2)) {
875 logger.debug("Menu Request: Source: {} - Value: {}", source.getNum(), updateData);
876 // For now we only support one level deep menus. If second field is '1', indicates go back to main
878 String[] menuDataSplit = updateData.split(COMMA);
879 if (menuDataSplit.length > 2 && ONE.equals(menuDataSplit[1])) {
881 connector.sendCommand(sourceZone + "MENU0xFFFFFFFF,0,0,0,0,0,0,\"\"");
882 } catch (NuvoException e) {
883 logger.debug("Error sending main menu command to {}", sourceZone);
888 case TYPE_ZONE_CONFIG:
889 logger.debug("Zone Configuration: Zone: {} - Value: {}", zone.getNum(), updateData);
890 // example: BASS1,TREB-2,BALR2,LOUDCMP1
891 Matcher matcher = ZONE_CFG_EQ_PATTERN.matcher(updateData);
892 if (matcher.find()) {
893 updateChannelState(zone, CHANNEL_TYPE_BASS, matcher.group(1));
894 updateChannelState(zone, CHANNEL_TYPE_TREBLE, matcher.group(2));
895 updateChannelState(zone, CHANNEL_TYPE_BALANCE, NuvoStatusCodes.getBalanceFromStr(matcher.group(3)));
896 updateChannelState(zone, CHANNEL_TYPE_LOUDNESS, ONE.equals(matcher.group(4)) ? ON : OFF);
898 matcher = ZONE_CFG_PATTERN.matcher(updateData);
899 // example: ENABLE1,NAME"Great Room",SLAVETO0,GROUP1,SOURCES63,XSRC0,IR1,DND0,LOCKED0,SLAVEEQ0
900 if (matcher.find()) {
901 // TODO: utilize other info such as zone name, available sources bitmask, etc.
903 // if this zone is a member of a group (1-4), add the zone's enum to the appropriate group map
904 if (!ZERO.equals(matcher.group(3))) {
905 nuvoGroupMap.get(matcher.group(3)).add(zone);
908 logger.debug("no match on message: {}", updateData);
912 case TYPE_NN_ALBUM_ART_REQ:
913 // ignore this update unless openHAB is handling this source
914 if (nuvoNetSrcMap.get(source).equals(2)) {
915 logger.debug("Album Art Request for Source: {} - Data: {}", source.getNum(), updateData);
916 // 0x620FD879,80,80,2,0x00C0C0C0,0,0,0,0,1
917 String[] albumArtReq = updateData.split(COMMA);
918 albumArtIds.put(source, Integer.decode(albumArtReq[0]));
921 if (albumArtMap.get(source).length > 1) {
922 connector.sendCommand(source.getId() + ALBUM_ART_AVAILABLE + albumArtIds.get(source) + COMMA
923 + albumArtMap.get(source).length);
925 connector.sendCommand(source.getId() + ALBUM_ART_AVAILABLE + ZERO_COMMA);
927 } catch (NuvoException e) {
928 logger.debug("Error sending ALBUMARTAVAILABLE command for source: {}", source.getNum());
932 case TYPE_NN_ALBUM_ART_FRAG_REQ:
933 // ignore this update unless openHAB is handling this source
934 if (nuvoNetSrcMap.get(source).equals(2)) {
935 logger.debug("Album Art Fragment Request for Source: {} - Data: {}", source.getNum(), updateData);
936 // 0x620FD879,0,750 (id, requested offset from start of image, byte length requested)
937 String[] albumArtFragReq = updateData.split(COMMA);
938 int requestedId = Integer.decode(albumArtFragReq[0]);
939 int offset = Integer.parseInt(albumArtFragReq[1]);
940 int length = Integer.parseInt(albumArtFragReq[2]);
942 if (requestedId == albumArtIds.get(source)) {
943 byte[] chunk = new byte[length];
944 byte[] albumArtBytes = albumArtMap.get(source);
946 if (albumArtBytes != null) {
947 System.arraycopy(albumArtBytes, offset, chunk, 0, length);
948 final String frag = Base64.getEncoder().encodeToString(chunk);
950 connector.sendCommand(source.getId() + ALBUM_ART_FRAG + requestedId + COMMA + offset
951 + COMMA + frag.length() + COMMA + frag);
952 } catch (NuvoException e) {
953 logger.debug("Error sending ALBUMARTFRAG command for source: {}, artId: {}",
954 source.getNum(), requestedId);
960 case TYPE_NN_FAVORITE_REQ:
961 // ignore this update unless openHAB is handling this source
962 if (nuvoNetSrcMap.get(source).equals(2)) {
963 logger.debug("Favorite request for source: {} - favoriteId: {}", source.getNum(), updateData);
965 int playlistIdx = Integer.parseInt(updateData, 16) - 1000;
966 updateChannelState(source, CHANNEL_BUTTON_PRESS,
967 PLAY_MUSIC_PRESET + favoriteMap.get(source)[playlistIdx]);
968 } catch (NumberFormatException nfe) {
969 logger.debug("Unable to parse favoriteId: {}", updateData);
974 logger.debug("onNewMessageEvent: unhandled event type {}", evt.getType());
975 // Return here because receiving an unknown message does not indicate that one can poll
979 if (isMps4 && pollStatusNeeded) {
984 private void loadMenuConfiguration(NuvoThingConfiguration config) {
985 StringBuilder menuXml = new StringBuilder("<menu>");
987 if (!config.menuXmlSrc1.isEmpty()) {
988 menuXml.append("<source>" + config.menuXmlSrc1 + "</source>");
990 menuXml.append("<source/>");
992 if (!config.menuXmlSrc2.isEmpty()) {
993 menuXml.append("<source>" + config.menuXmlSrc2 + "</source>");
995 menuXml.append("<source/>");
997 if (!config.menuXmlSrc3.isEmpty()) {
998 menuXml.append("<source>" + config.menuXmlSrc3 + "</source>");
1000 menuXml.append("<source/>");
1002 if (!config.menuXmlSrc4.isEmpty()) {
1003 menuXml.append("<source>" + config.menuXmlSrc4 + "</source>");
1005 menuXml.append("<source/>");
1007 if (!config.menuXmlSrc5.isEmpty()) {
1008 menuXml.append("<source>" + config.menuXmlSrc5 + "</source>");
1010 menuXml.append("<source/>");
1012 if (!config.menuXmlSrc6.isEmpty()) {
1013 menuXml.append("<source>" + config.menuXmlSrc6 + "</source>");
1015 menuXml.append("<source/>");
1017 menuXml.append("</menu>");
1020 JAXBContext ctx = JAXBUtils.JAXBCONTEXT_NUVO_MENU;
1022 Unmarshaller unmarshaller = ctx.createUnmarshaller();
1023 if (unmarshaller != null) {
1024 XMLStreamReader xsr = JAXBUtils.XMLINPUTFACTORY
1025 .createXMLStreamReader(new StringReader(menuXml.toString()));
1026 NuvoMenu menu = (NuvoMenu) unmarshaller.unmarshal(xsr);
1033 logger.debug("No JAXBContext available to parse Nuvo Menu XML");
1034 } catch (JAXBException | XMLStreamException e) {
1035 logger.warn("Error processing Nuvo Menu XML: {}", e.getLocalizedMessage());
1039 private void enableNuvonet(boolean showReady) {
1040 if (!this.isAnyOhNuvoNet) {
1044 // enable NuvoNet for each source configured as an openHAB NuvoNet source
1045 nuvoNetSrcMap.forEach((source, val) -> {
1046 if (val.equals(2)) {
1048 connector.sendCommand(source.getConfigId() + "NUVONET1");
1049 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1050 } catch (NuvoException | InterruptedException e) {
1051 logger.debug("Error sending SCFG command for source: {}", source.getNum());
1057 // set '1' flag for each source configured as an MPS4 NuvoNet source or openHAB NuvoNet source
1058 connector.sendCommand("SNUMBERS" + nuvoNetSrcMap.get(NuvoEnum.SOURCE1).compareTo(0) + COMMA
1059 + nuvoNetSrcMap.get(NuvoEnum.SOURCE2).compareTo(0) + COMMA
1060 + nuvoNetSrcMap.get(NuvoEnum.SOURCE3).compareTo(0) + COMMA
1061 + nuvoNetSrcMap.get(NuvoEnum.SOURCE4).compareTo(0) + COMMA
1062 + nuvoNetSrcMap.get(NuvoEnum.SOURCE5).compareTo(0) + COMMA
1063 + nuvoNetSrcMap.get(NuvoEnum.SOURCE6).compareTo(0));
1064 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1065 } catch (NuvoException | InterruptedException e) {
1066 logger.debug("Error sending SNUMBERS command");
1069 // go though each source and if is openHAB NuvoNet then configure menu, favorites, etc.
1070 nuvoNetSrcMap.forEach((source, val) -> {
1071 if (val.equals(2)) {
1073 List<TopMenu> topMenuItems = nuvoMenus.getSource().get(source.getNum() - 1).getTopMenu();
1075 if (!topMenuItems.isEmpty()) {
1076 connector.sendCommand(
1077 source.getId() + "MENU," + (topMenuItems.size() < 10 ? topMenuItems.size() : 10));
1078 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1080 for (int i = 0; i < (topMenuItems.size() < 10 ? topMenuItems.size() : 10); i++) {
1081 connector.sendCommand(source.getId() + "MENUITEM" + (i + 1) + ","
1082 + (topMenuItems.get(i).getItems().isEmpty() ? ZERO : ONE) + ",0,\""
1083 + topMenuItems.get(i).getText() + "\"");
1084 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1088 String[] favorites = favoriteMap.get(source);
1089 if (favorites != null) {
1090 connector.sendCommand(source.getId() + "FAVORITES"
1091 + (favorites.length < 20 ? favorites.length : 20) + COMMA
1092 + (source.getNum() == 1 ? ONE : ZERO) + COMMA + (source.getNum() == 2 ? ONE : ZERO)
1093 + COMMA + (source.getNum() == 3 ? ONE : ZERO) + COMMA
1094 + (source.getNum() == 4 ? ONE : ZERO) + COMMA + (source.getNum() == 5 ? ONE : ZERO)
1095 + COMMA + (source.getNum() == 6 ? ONE : ZERO));
1096 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1098 for (int i = 0; i < (favorites.length < 20 ? favorites.length : 20); i++) {
1099 connector.sendCommand(source.getId() + "FAVORITESITEM" + (i + 1000) + ",0,0,\""
1100 + favPrefixMap.get(source) + favorites[i] + "\"");
1101 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1106 connector.sendCommand(source.getId() + "DISPINFOTWO0,0,0,0,0,0,0");
1107 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1108 connector.sendCommand(source.getId() + "DISPLINES0,0,0,\"Ready\",\"\",\"\",\"\"");
1109 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1112 } catch (NuvoException | InterruptedException e) {
1113 logger.debug("Error configuring NuvoNet for source: {}", source.getNum());
1120 * Schedule the reconnection job
1122 private void scheduleReconnectJob() {
1123 logger.debug("Schedule reconnect job");
1124 cancelReconnectJob();
1125 reconnectJob = scheduler.scheduleWithFixedDelay(() -> {
1126 if (!connector.isConnected()) {
1127 logger.debug("Trying to reconnect...");
1129 if (openConnection()) {
1130 logger.debug("Reconnected");
1131 // Polling status will disconnect from MPS4 on reconnect
1135 enableNuvonet(true);
1137 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Reconnection failed");
1141 }, 1, RECON_POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
1145 * If a ping is not received within ping interval the connection is closed and a reconnect job is scheduled
1147 private void schedulePingTimeoutJob() {
1149 logger.debug("Schedule Ping Timeout job");
1150 cancelPingTimeoutJob();
1151 pingJob = scheduler.schedule(() -> {
1153 scheduleReconnectJob();
1154 }, PING_TIMEOUT_SEC, TimeUnit.SECONDS);
1156 logger.debug("Ping Timeout job not valid for serial connections");
1161 * Cancel the ping timeout job
1163 private void cancelPingTimeoutJob() {
1164 ScheduledFuture<?> pingJob = this.pingJob;
1165 if (pingJob != null) {
1166 pingJob.cancel(true);
1167 this.pingJob = null;
1171 private void pollStatus() {
1172 pollStatusNeeded = false;
1173 scheduler.submit(() -> {
1174 synchronized (sequenceLock) {
1176 connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
1178 NuvoEnum.VALID_SOURCES.forEach(source -> {
1180 connector.sendQuery(source, NuvoCommand.NAME);
1181 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1182 connector.sendQuery(source, NuvoCommand.DISPINFO);
1183 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1184 connector.sendQuery(source, NuvoCommand.DISPLINE);
1185 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1186 } catch (NuvoException | InterruptedException e) {
1187 logger.debug("Error Querying Source data: {}", e.getMessage());
1191 // Query all active zones to get their current status and eq configuration
1192 activeZones.forEach(zoneNum -> {
1194 connector.sendQuery(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.STATUS);
1195 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1196 connector.sendCfgCommand(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.STATUS_QUERY, BLANK);
1197 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1198 connector.sendCfgCommand(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.EQ_QUERY, BLANK);
1199 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
1200 } catch (NuvoException | InterruptedException e) {
1201 logger.debug("Error Querying Zone data: {}", e.getMessage());
1205 List<StateOption> sourceStateOptions = new ArrayList<>();
1206 sourceLabels.keySet().forEach(key -> {
1207 sourceStateOptions.add(new StateOption(key, sourceLabels.get(key)));
1210 // Put the source labels on all active zones
1211 activeZones.forEach(zoneNum -> {
1212 stateDescriptionProvider.setStateOptions(
1213 new ChannelUID(getThing().getUID(),
1214 ZONE.toLowerCase() + zoneNum + CHANNEL_DELIMIT + CHANNEL_TYPE_SOURCE),
1215 sourceStateOptions);
1217 } catch (NuvoException e) {
1218 logger.debug("Error polling status from Nuvo: {}", e.getMessage());
1225 * Cancel the reconnection job
1227 private void cancelReconnectJob() {
1228 ScheduledFuture<?> reconnectJob = this.reconnectJob;
1229 if (reconnectJob != null) {
1230 reconnectJob.cancel(true);
1231 this.reconnectJob = null;
1236 * Schedule the polling job
1238 private void schedulePollingJob() {
1242 logger.debug("MPS4 doesn't support polling");
1245 logger.debug("Schedule polling job");
1248 // when the Nuvo amp is off, this will keep the connection (esp Serial over IP) alive and detect if the
1249 // connection goes down
1250 pollingJob = scheduler.scheduleWithFixedDelay(() -> {
1251 if (connector.isConnected()) {
1252 logger.debug("Polling the component for updated status...");
1254 synchronized (sequenceLock) {
1256 connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
1257 } catch (NuvoException e) {
1258 logger.debug("Polling error: {}", e.getMessage());
1261 // if the last event received was more than 1.25 intervals ago,
1262 // the component is not responding even though the connection is still good
1263 if ((System.currentTimeMillis() - lastEventReceived) > (POLLING_INTERVAL_SEC * 1.25 * 1000)) {
1264 logger.debug("Component not responding to status requests");
1265 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1266 "Component not responding to status requests");
1268 scheduleReconnectJob();
1272 }, INITIAL_POLLING_DELAY_SEC, POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
1276 * Cancel the polling job
1278 private void cancelPollingJob() {
1279 ScheduledFuture<?> pollingJob = this.pollingJob;
1280 if (pollingJob != null) {
1281 pollingJob.cancel(true);
1282 this.pollingJob = null;
1287 * Schedule the clock sync job
1289 private void scheduleClockSyncJob() {
1290 logger.debug("Schedule clock sync job");
1291 cancelClockSyncJob();
1292 clockSyncJob = scheduler.scheduleWithFixedDelay(() -> {
1293 if (this.isGConcerto) {
1295 SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy,MM,dd,HH,mm");
1296 connector.sendCommand(NuvoCommand.CFGTIME.getValue() + dateFormat.format(new Date()));
1297 } catch (NuvoException e) {
1298 logger.debug("Error syncing clock: {}", e.getMessage());
1301 this.cancelClockSyncJob();
1303 }, INITIAL_CLOCK_SYNC_DELAY_SEC, CLOCK_SYNC_INTERVAL_SEC, TimeUnit.SECONDS);
1307 * Cancel the clock sync job
1309 private void cancelClockSyncJob() {
1310 ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
1311 if (clockSyncJob != null) {
1312 clockSyncJob.cancel(true);
1313 this.clockSyncJob = null;
1318 * Update the state of a channel (original method signature)
1320 * @param target the channel group
1321 * @param channelType the channel group item
1322 * @param value the value to be updated
1324 private void updateChannelState(NuvoEnum target, String channelType, String value) {
1325 updateChannelState(target, channelType, value, NO_ART);
1329 * Update the state of a channel (overloaded method to handle album_art channel)
1331 * @param target the channel group
1332 * @param channelType the channel group item
1333 * @param value the value to be updated
1334 * @param bytes the byte[] to load into the Image channel
1336 private void updateChannelState(NuvoEnum target, String channelType, String value, byte[] bytes) {
1337 String channel = target.name().toLowerCase() + CHANNEL_DELIMIT + channelType;
1339 if (!isLinked(channel)) {
1343 State state = UnDefType.UNDEF;
1345 if (UNDEF.equals(value)) {
1346 updateState(channel, state);
1350 switch (channelType) {
1351 case CHANNEL_TYPE_POWER:
1352 case CHANNEL_TYPE_MUTE:
1353 case CHANNEL_TYPE_DND:
1354 case CHANNEL_TYPE_PARTY:
1355 case CHANNEL_TYPE_ALLMUTE:
1356 case CHANNEL_TYPE_PAGE:
1357 case CHANNEL_TYPE_LOUDNESS:
1358 state = OnOffType.from(ON.equals(value));
1360 case CHANNEL_TYPE_LOCK:
1361 state = ON.equals(value) ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
1363 case CHANNEL_TYPE_SOURCE:
1364 case CHANNEL_TYPE_TREBLE:
1365 case CHANNEL_TYPE_BASS:
1366 case CHANNEL_TYPE_BALANCE:
1367 state = new DecimalType(value);
1369 case CHANNEL_TYPE_VOLUME:
1370 int volume = Integer.parseInt(value);
1371 long volumePct = Math
1372 .round((double) (MAX_VOLUME - volume) / (double) (MAX_VOLUME - MIN_VOLUME) * 100.0);
1373 state = new PercentType(BigDecimal.valueOf(volumePct));
1375 case CHANNEL_TYPE_BUTTONPRESS:
1376 case CHANNEL_DISPLAY_LINE1:
1377 case CHANNEL_DISPLAY_LINE2:
1378 case CHANNEL_DISPLAY_LINE3:
1379 case CHANNEL_DISPLAY_LINE4:
1380 case CHANNEL_BUTTON_PRESS:
1381 state = new StringType(value);
1383 case CHANNEL_PLAY_MODE:
1384 state = new StringType(NuvoStatusCodes.PLAY_MODE.get(value));
1386 case CHANNEL_TRACK_LENGTH:
1387 case CHANNEL_TRACK_POSITION:
1388 state = new QuantityType<Time>(Integer.parseInt(value) / 10, NuvoHandler.API_SECOND_UNIT);
1390 case CHANNEL_ALBUM_ART:
1391 state = new RawType(bytes, "image/jpeg");
1396 updateState(channel, state);
1400 * For grouped zones, update the source channel for all group members
1402 * @param zoneEnum the zone where the source was changed
1403 * @param srcId the new source number that was selected
1405 private void updateSrcForZoneGroup(NuvoEnum zoneEnum, String srcId) {
1406 // check if this zone is in a group, if so update the other group member's selected source
1407 nuvoGroupMap.forEach((groupId, groupZones) -> {
1408 if (groupZones.contains(zoneEnum)) {
1409 groupZones.forEach(z -> {
1410 if (!zoneEnum.equals(z)) {
1411 updateChannelState(z, CHANNEL_TYPE_SOURCE, srcId);
1419 * Handle a button press from a UI Player item
1421 * @param target the nuvo zone to receive the command
1422 * @param command the button press command to send to the zone
1424 private void handleControlCommand(NuvoEnum target, Command command) throws NuvoException {
1425 if (command instanceof PlayPauseType) {
1426 connector.sendCommand(target, NuvoCommand.PLAYPAUSE);
1427 } else if (command instanceof NextPreviousType) {
1428 if (command == NextPreviousType.NEXT) {
1429 connector.sendCommand(target, NuvoCommand.NEXT);
1430 } else if (command == NextPreviousType.PREVIOUS) {
1431 connector.sendCommand(target, NuvoCommand.PREV);
1434 logger.warn("Unknown control command: {}", command);