2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.nuvo.internal.handler;
15 import static org.openhab.binding.nuvo.internal.NuvoBindingConstants.*;
17 import java.math.BigDecimal;
18 import java.text.SimpleDateFormat;
19 import java.util.ArrayList;
20 import java.util.Collection;
21 import java.util.Collections;
22 import java.util.Date;
23 import java.util.HashSet;
24 import java.util.List;
26 import java.util.TreeMap;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
29 import java.util.regex.Matcher;
30 import java.util.regex.Pattern;
31 import java.util.stream.Collectors;
32 import java.util.stream.IntStream;
34 import javax.measure.Unit;
35 import javax.measure.quantity.Time;
37 import org.eclipse.jdt.annotation.NonNullByDefault;
38 import org.eclipse.jdt.annotation.Nullable;
39 import org.openhab.binding.nuvo.internal.NuvoException;
40 import org.openhab.binding.nuvo.internal.NuvoStateDescriptionOptionProvider;
41 import org.openhab.binding.nuvo.internal.NuvoThingActions;
42 import org.openhab.binding.nuvo.internal.communication.NuvoCommand;
43 import org.openhab.binding.nuvo.internal.communication.NuvoConnector;
44 import org.openhab.binding.nuvo.internal.communication.NuvoDefaultConnector;
45 import org.openhab.binding.nuvo.internal.communication.NuvoEnum;
46 import org.openhab.binding.nuvo.internal.communication.NuvoIpConnector;
47 import org.openhab.binding.nuvo.internal.communication.NuvoMessageEvent;
48 import org.openhab.binding.nuvo.internal.communication.NuvoMessageEventListener;
49 import org.openhab.binding.nuvo.internal.communication.NuvoSerialConnector;
50 import org.openhab.binding.nuvo.internal.communication.NuvoStatusCodes;
51 import org.openhab.binding.nuvo.internal.configuration.NuvoThingConfiguration;
52 import org.openhab.core.io.transport.serial.SerialPortManager;
53 import org.openhab.core.library.types.DecimalType;
54 import org.openhab.core.library.types.NextPreviousType;
55 import org.openhab.core.library.types.OnOffType;
56 import org.openhab.core.library.types.OpenClosedType;
57 import org.openhab.core.library.types.PercentType;
58 import org.openhab.core.library.types.PlayPauseType;
59 import org.openhab.core.library.types.QuantityType;
60 import org.openhab.core.library.types.StringType;
61 import org.openhab.core.library.unit.Units;
62 import org.openhab.core.thing.Channel;
63 import org.openhab.core.thing.ChannelUID;
64 import org.openhab.core.thing.Thing;
65 import org.openhab.core.thing.ThingStatus;
66 import org.openhab.core.thing.ThingStatusDetail;
67 import org.openhab.core.thing.binding.BaseThingHandler;
68 import org.openhab.core.thing.binding.ThingHandlerService;
69 import org.openhab.core.types.Command;
70 import org.openhab.core.types.State;
71 import org.openhab.core.types.StateOption;
72 import org.openhab.core.types.UnDefType;
73 import org.slf4j.Logger;
74 import org.slf4j.LoggerFactory;
77 * The {@link NuvoHandler} is responsible for handling commands, which are sent to one of the channels.
79 * Based on the Rotel binding by Laurent Garnier
81 * @author Michael Lobstein - Initial contribution
84 public class NuvoHandler extends BaseThingHandler implements NuvoMessageEventListener {
85 private static final long RECON_POLLING_INTERVAL_SEC = 60;
86 private static final long POLLING_INTERVAL_SEC = 30;
87 private static final long CLOCK_SYNC_INTERVAL_SEC = 3600;
88 private static final long INITIAL_POLLING_DELAY_SEC = 30;
89 private static final long INITIAL_CLOCK_SYNC_DELAY_SEC = 10;
90 private static final long PING_TIMEOUT_SEC = 60;
91 // spec says wait 50ms, min is 100
92 private static final long SLEEP_BETWEEN_CMD_MS = 100;
93 private static final Unit<Time> API_SECOND_UNIT = Units.SECOND;
95 private static final String ZONE = "ZONE";
96 private static final String SOURCE = "SOURCE";
97 private static final String CHANNEL_DELIMIT = "#";
98 private static final String UNDEF = "UNDEF";
99 private static final String GC_STR = "NV-I8G";
101 private static final int MAX_ZONES = 20;
102 private static final int MAX_SRC = 6;
103 private static final int MAX_FAV = 12;
104 private static final int MIN_VOLUME = 0;
105 private static final int MAX_VOLUME = 79;
106 private static final int MIN_EQ = -18;
107 private static final int MAX_EQ = 18;
109 private static final int MPS4_PORT = 5006;
111 private static final Pattern ZONE_PATTERN = Pattern
112 .compile("^ON,SRC(\\d{1}),(MUTE|VOL\\d{1,2}),DND([0-1]),LOCK([0-1])$");
113 private static final Pattern DISP_PATTERN = Pattern.compile("^DISPLINE(\\d{1}),\"(.*)\"$");
114 private static final Pattern DISP_INFO_PATTERN = Pattern
115 .compile("^DISPINFO,DUR(\\d{1,6}),POS(\\d{1,6}),STATUS(\\d{1,2})$");
116 private static final Pattern ZONE_CFG_PATTERN = Pattern.compile("^BASS(.*),TREB(.*),BAL(.*),LOUDCMP([0-1])$");
118 private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy,MM,dd,HH,mm");
120 private final Logger logger = LoggerFactory.getLogger(NuvoHandler.class);
121 private final NuvoStateDescriptionOptionProvider stateDescriptionProvider;
122 private final SerialPortManager serialPortManager;
124 private @Nullable ScheduledFuture<?> reconnectJob;
125 private @Nullable ScheduledFuture<?> pollingJob;
126 private @Nullable ScheduledFuture<?> clockSyncJob;
127 private @Nullable ScheduledFuture<?> pingJob;
129 private NuvoConnector connector = new NuvoDefaultConnector();
130 private long lastEventReceived = System.currentTimeMillis();
131 private int numZones = 1;
132 private String versionString = BLANK;
133 private boolean isGConcerto = false;
134 private Object sequenceLock = new Object();
136 Set<Integer> activeZones = new HashSet<>(1);
138 // A tree map that maps the source ids to source labels
139 TreeMap<String, String> sourceLabels = new TreeMap<String, String>();
141 // Indicates if there is a need to poll status because of a disconnection used for MPS4 systems
142 boolean pollStatusNeeded = true;
143 boolean isMps4 = false;
148 public NuvoHandler(Thing thing, NuvoStateDescriptionOptionProvider stateDescriptionProvider,
149 SerialPortManager serialPortManager) {
151 this.stateDescriptionProvider = stateDescriptionProvider;
152 this.serialPortManager = serialPortManager;
156 public void initialize() {
157 final String uid = this.getThing().getUID().getAsString();
158 NuvoThingConfiguration config = getConfigAs(NuvoThingConfiguration.class);
159 final String serialPort = config.serialPort;
160 final String host = config.host;
161 final Integer port = config.port;
162 final Integer numZones = config.numZones;
164 // Check configuration settings
165 String configError = null;
166 if ((serialPort == null || serialPort.isEmpty()) && (host == null || host.isEmpty())) {
167 configError = "undefined serialPort and host configuration settings; please set one of them";
168 } else if (serialPort != null && (host == null || host.isEmpty())) {
169 if (serialPort.toLowerCase().startsWith("rfc2217")) {
170 configError = "use host and port configuration settings for a serial over IP connection";
174 configError = "undefined port configuration setting";
175 } else if (port <= 0) {
176 configError = "invalid port configuration setting";
180 if (configError != null) {
181 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configError);
185 if (serialPort != null) {
186 connector = new NuvoSerialConnector(serialPortManager, serialPort, uid);
187 } else if (port != null) {
188 connector = new NuvoIpConnector(host, port, uid);
190 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
191 "Either Serial port or Host & Port must be specifed");
195 this.isMps4 = (port != null && port.intValue() == MPS4_PORT);
197 logger.debug("Port set to {} configuring binding for MPS4 compatability", MPS4_PORT);
200 if (numZones != null) {
201 this.numZones = numZones;
204 activeZones = IntStream.range((1), (this.numZones + 1)).boxed().collect(Collectors.toSet());
206 // remove the channels for the zones we are not using
207 if (this.numZones < MAX_ZONES) {
208 List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
210 List<Integer> zonesToRemove = IntStream.range((this.numZones + 1), (MAX_ZONES + 1)).boxed()
211 .collect(Collectors.toList());
213 zonesToRemove.forEach(zone -> channels.removeIf(c -> (c.getUID().getId().contains("zone" + zone))));
214 updateThing(editThing().withChannels(channels).build());
217 if (config.clockSync) {
218 scheduleClockSyncJob();
221 scheduleReconnectJob();
222 schedulePollingJob();
223 schedulePingTimeoutJob();
224 updateStatus(ThingStatus.UNKNOWN);
228 public void dispose() {
229 cancelReconnectJob();
231 cancelClockSyncJob();
232 cancelPingTimeoutJob();
238 public Collection<Class<? extends ThingHandlerService>> getServices() {
239 return Collections.singletonList(NuvoThingActions.class);
242 public void handleRawCommand(@Nullable String command) {
243 synchronized (sequenceLock) {
245 connector.sendCommand(command);
246 } catch (NuvoException e) {
247 logger.warn("Nuvo Command: {} failed", command);
253 * Handle a command the UI
255 * @param channelUID the channel sending the command
256 * @param command the command received
260 public void handleCommand(ChannelUID channelUID, Command command) {
261 String channel = channelUID.getId();
262 String[] channelSplit = channel.split(CHANNEL_DELIMIT);
263 NuvoEnum target = NuvoEnum.valueOf(channelSplit[0].toUpperCase());
265 String channelType = channelSplit[1];
267 if (getThing().getStatus() != ThingStatus.ONLINE) {
268 logger.debug("Thing is not ONLINE; command {} from channel {} is ignored", command, channel);
272 synchronized (sequenceLock) {
273 if (!connector.isConnected()) {
274 logger.warn("Command {} from channel {} is ignored: connection not established", command, channel);
279 switch (channelType) {
280 case CHANNEL_TYPE_POWER:
281 if (command instanceof OnOffType) {
282 connector.sendCommand(target, command == OnOffType.ON ? NuvoCommand.ON : NuvoCommand.OFF);
285 case CHANNEL_TYPE_SOURCE:
286 if (command instanceof DecimalType) {
287 int value = ((DecimalType) command).intValue();
288 if (value >= 1 && value <= MAX_SRC) {
289 logger.debug("Got source command {} zone {}", value, target);
290 connector.sendCommand(target, NuvoCommand.SOURCE, String.valueOf(value));
294 case CHANNEL_TYPE_FAVORITE:
295 if (command instanceof DecimalType) {
296 int value = ((DecimalType) command).intValue();
297 if (value >= 1 && value <= MAX_FAV) {
298 logger.debug("Got favorite command {} zone {}", value, target);
299 connector.sendCommand(target, NuvoCommand.FAVORITE, String.valueOf(value));
303 case CHANNEL_TYPE_VOLUME:
304 if (command instanceof PercentType) {
305 int value = (MAX_VOLUME
307 ((PercentType) command).doubleValue() / 100.0 * (MAX_VOLUME - MIN_VOLUME))
309 logger.debug("Got volume command {} zone {}", value, target);
310 connector.sendCommand(target, NuvoCommand.VOLUME, String.valueOf(value));
313 case CHANNEL_TYPE_MUTE:
314 if (command instanceof OnOffType) {
315 connector.sendCommand(target,
316 command == OnOffType.ON ? NuvoCommand.MUTE_ON : NuvoCommand.MUTE_OFF);
319 case CHANNEL_TYPE_TREBLE:
320 if (command instanceof DecimalType) {
321 int value = ((DecimalType) command).intValue();
322 if (value >= MIN_EQ && value <= MAX_EQ) {
323 // device can only accept even values
324 if (value % 2 == 1) {
327 logger.debug("Got treble command {} zone {}", value, target);
328 connector.sendCfgCommand(target, NuvoCommand.TREBLE, String.valueOf(value));
332 case CHANNEL_TYPE_BASS:
333 if (command instanceof DecimalType) {
334 int value = ((DecimalType) command).intValue();
335 if (value >= MIN_EQ && value <= MAX_EQ) {
336 if (value % 2 == 1) {
339 logger.debug("Got bass command {} zone {}", value, target);
340 connector.sendCfgCommand(target, NuvoCommand.BASS, String.valueOf(value));
344 case CHANNEL_TYPE_BALANCE:
345 if (command instanceof DecimalType) {
346 int value = ((DecimalType) command).intValue();
347 if (value >= MIN_EQ && value <= MAX_EQ) {
348 if (value % 2 == 1) {
351 logger.debug("Got balance command {} zone {}", value, target);
352 connector.sendCfgCommand(target, NuvoCommand.BALANCE,
353 NuvoStatusCodes.getBalanceFromInt(value));
357 case CHANNEL_TYPE_LOUDNESS:
358 if (command instanceof OnOffType) {
359 connector.sendCfgCommand(target, NuvoCommand.LOUDNESS,
360 command == OnOffType.ON ? ONE : ZERO);
363 case CHANNEL_TYPE_CONTROL:
364 handleControlCommand(target, command);
366 case CHANNEL_TYPE_DND:
367 if (command instanceof OnOffType) {
368 connector.sendCommand(target,
369 command == OnOffType.ON ? NuvoCommand.DND_ON : NuvoCommand.DND_OFF);
372 case CHANNEL_TYPE_PARTY:
373 if (command instanceof OnOffType) {
374 connector.sendCommand(target,
375 command == OnOffType.ON ? NuvoCommand.PARTY_ON : NuvoCommand.PARTY_OFF);
378 case CHANNEL_DISPLAY_LINE1:
379 if (command instanceof StringType) {
380 connector.sendCommand(target, NuvoCommand.DISPLINE1, "\"" + command + "\"");
383 case CHANNEL_DISPLAY_LINE2:
384 if (command instanceof StringType) {
385 connector.sendCommand(target, NuvoCommand.DISPLINE2, "\"" + command + "\"");
388 case CHANNEL_DISPLAY_LINE3:
389 if (command instanceof StringType) {
390 connector.sendCommand(target, NuvoCommand.DISPLINE3, "\"" + command + "\"");
393 case CHANNEL_DISPLAY_LINE4:
394 if (command instanceof StringType) {
395 connector.sendCommand(target, NuvoCommand.DISPLINE4, "\"" + command + "\"");
398 case CHANNEL_TYPE_ALLOFF:
399 if (command instanceof OnOffType) {
400 connector.sendCommand(NuvoCommand.ALLOFF);
403 case CHANNEL_TYPE_ALLMUTE:
404 if (command instanceof OnOffType) {
405 connector.sendCommand(
406 command == OnOffType.ON ? NuvoCommand.ALLMUTE_ON : NuvoCommand.ALLMUTE_OFF);
409 case CHANNEL_TYPE_PAGE:
410 if (command instanceof OnOffType) {
411 connector.sendCommand(command == OnOffType.ON ? NuvoCommand.PAGE_ON : NuvoCommand.PAGE_OFF);
415 } catch (NuvoException e) {
416 logger.warn("Command {} from channel {} failed: {}", command, channel, e.getMessage());
417 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Sending command failed");
419 scheduleReconnectJob();
425 * Open the connection with the Nuvo device
427 * @return true if the connection is opened successfully or false if not
429 private synchronized boolean openConnection() {
430 connector.addEventListener(this);
433 } catch (NuvoException e) {
434 logger.debug("openConnection() failed: {}", e.getMessage());
436 logger.debug("openConnection(): {}", connector.isConnected() ? "connected" : "disconnected");
437 return connector.isConnected();
441 * Close the connection with the Nuvo device
443 private synchronized void closeConnection() {
444 if (connector.isConnected()) {
446 connector.removeEventListener(this);
447 pollStatusNeeded = true;
448 logger.debug("closeConnection(): disconnected");
453 * Handle an event received from the Nuvo device
455 * @param event the event to process
458 public void onNewMessageEvent(NuvoMessageEvent evt) {
459 logger.debug("onNewMessageEvent: key {} = {}", evt.getKey(), evt.getValue());
460 lastEventReceived = System.currentTimeMillis();
462 String type = evt.getType();
463 String key = evt.getKey();
464 String updateData = evt.getValue().trim();
465 if (this.getThing().getStatus() == ThingStatus.OFFLINE) {
466 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, this.versionString);
471 this.versionString = updateData;
472 // Determine if we are a Grand Concerto or not
473 if (this.versionString.contains(GC_STR)) {
474 logger.debug("Grand Concerto detected");
475 this.isGConcerto = true;
476 connector.setEssentia(false);
478 logger.debug("Grand Concerto not detected");
482 logger.debug("Ping message received- rescheduling ping timeout");
483 schedulePingTimeoutJob();
484 // Return here because receiving a ping does not indicate that one can poll
487 activeZones.forEach(zoneNum -> {
488 updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_POWER, OFF);
492 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_ALLMUTE, ONE.equals(updateData) ? ON : OFF);
493 activeZones.forEach(zoneNum -> {
494 updateChannelState(NuvoEnum.valueOf(ZONE + zoneNum), CHANNEL_TYPE_MUTE,
495 ONE.equals(updateData) ? ON : OFF);
499 updateChannelState(NuvoEnum.SYSTEM, CHANNEL_TYPE_PAGE, ONE.equals(updateData) ? ON : OFF);
501 case TYPE_SOURCE_UPDATE:
502 logger.debug("Source update: Source: {} - Value: {}", key, updateData);
503 NuvoEnum targetSource = NuvoEnum.valueOf(SOURCE + key);
505 if (updateData.contains(DISPLINE)) {
506 // example: DISPLINE2,"Play My Song (Featuring Dee Ajayi)"
507 Matcher matcher = DISP_PATTERN.matcher(updateData);
508 if (matcher.find()) {
509 updateChannelState(targetSource, CHANNEL_DISPLAY_LINE + matcher.group(1), matcher.group(2));
511 logger.debug("no match on message: {}", updateData);
513 } else if (updateData.contains(DISPINFO)) {
514 // example: DISPINFO,DUR0,POS70,STATUS2 (DUR and POS are expressed in tenths of a second)
515 // 6 places(tenths of a second)-> max 999,999 /10/60/60/24 = 1.15 days
516 Matcher matcher = DISP_INFO_PATTERN.matcher(updateData);
517 if (matcher.find()) {
518 updateChannelState(targetSource, CHANNEL_TRACK_LENGTH, matcher.group(1));
519 updateChannelState(targetSource, CHANNEL_TRACK_POSITION, matcher.group(2));
520 updateChannelState(targetSource, CHANNEL_PLAY_MODE, matcher.group(3));
522 logger.debug("no match on message: {}", updateData);
524 } else if (updateData.contains(NAME_QUOTE)) {
525 // example: NAME"Ipod"
526 String name = updateData.split("\"")[1];
527 sourceLabels.put(key, name);
530 case TYPE_ZONE_UPDATE:
531 logger.debug("Zone update: Zone: {} - Value: {}", key, updateData);
533 // or: ON,SRC3,VOL63,DND0,LOCK0
534 // or: ON,SRC3,MUTE,DND0,LOCK0
536 NuvoEnum targetZone = NuvoEnum.valueOf(ZONE + key);
538 if (OFF.equals(updateData)) {
539 updateChannelState(targetZone, CHANNEL_TYPE_POWER, OFF);
540 updateChannelState(targetZone, CHANNEL_TYPE_SOURCE, UNDEF);
542 Matcher matcher = ZONE_PATTERN.matcher(updateData);
543 if (matcher.find()) {
544 updateChannelState(targetZone, CHANNEL_TYPE_POWER, ON);
545 updateChannelState(targetZone, CHANNEL_TYPE_SOURCE, matcher.group(1));
547 if (MUTE.equals(matcher.group(2))) {
548 updateChannelState(targetZone, CHANNEL_TYPE_MUTE, ON);
550 updateChannelState(targetZone, CHANNEL_TYPE_MUTE, NuvoCommand.OFF.getValue());
551 updateChannelState(targetZone, CHANNEL_TYPE_VOLUME, matcher.group(2).replace(VOL, BLANK));
554 updateChannelState(targetZone, CHANNEL_TYPE_DND, ONE.equals(matcher.group(3)) ? ON : OFF);
555 updateChannelState(targetZone, CHANNEL_TYPE_LOCK, ONE.equals(matcher.group(4)) ? ON : OFF);
557 logger.debug("no match on message: {}", updateData);
561 case TYPE_ZONE_BUTTON:
562 logger.debug("Zone Button pressed: Source: {} - Button: {}", key, updateData);
563 updateChannelState(NuvoEnum.valueOf(SOURCE + key), CHANNEL_BUTTON_PRESS, updateData);
565 case TYPE_ZONE_CONFIG:
566 logger.debug("Zone Configuration: Zone: {} - Value: {}", key, updateData);
567 // example: BASS1,TREB-2,BALR2,LOUDCMP1
568 Matcher matcher = ZONE_CFG_PATTERN.matcher(updateData);
569 if (matcher.find()) {
570 updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_BASS, matcher.group(1));
571 updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_TREBLE, matcher.group(2));
572 updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_BALANCE,
573 NuvoStatusCodes.getBalanceFromStr(matcher.group(3)));
574 updateChannelState(NuvoEnum.valueOf(ZONE + key), CHANNEL_TYPE_LOUDNESS,
575 ONE.equals(matcher.group(4)) ? ON : OFF);
577 logger.debug("no match on message: {}", updateData);
581 logger.debug("onNewMessageEvent: unhandled key {}", key);
582 // Return here because receiving an unknown message does not indicate that one can poll
586 if (isMps4 && pollStatusNeeded) {
592 * Schedule the reconnection job
594 private void scheduleReconnectJob() {
595 logger.debug("Schedule reconnect job");
596 cancelReconnectJob();
597 reconnectJob = scheduler.scheduleWithFixedDelay(() -> {
598 if (!connector.isConnected()) {
599 logger.debug("Trying to reconnect...");
602 if (openConnection()) {
603 logger.debug("Reconnected");
604 // Polling status will disconnect from MPS4 on reconnect
609 error = "Reconnection failed";
612 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, error);
615 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, this.versionString);
618 }, 1, RECON_POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
622 * If a ping is not received within ping interval the connection is closed and a reconnect job is scheduled
624 private void schedulePingTimeoutJob() {
626 logger.debug("Schedule Ping Timeout job");
627 cancelPingTimeoutJob();
628 pingJob = scheduler.schedule(() -> {
630 scheduleReconnectJob();
631 }, PING_TIMEOUT_SEC, TimeUnit.SECONDS);
633 logger.debug("Ping Timeout job on valid for MPS4 connections");
638 * Cancel the ping timeout job
640 private void cancelPingTimeoutJob() {
641 ScheduledFuture<?> pingJob = this.pingJob;
642 if (pingJob != null) {
643 pingJob.cancel(true);
648 private void pollStatus() {
649 pollStatusNeeded = false;
650 scheduler.submit(() -> {
651 synchronized (sequenceLock) {
653 connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
655 NuvoEnum.VALID_SOURCES.forEach(source -> {
657 connector.sendQuery(NuvoEnum.valueOf(source), NuvoCommand.NAME);
658 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
659 connector.sendQuery(NuvoEnum.valueOf(source), NuvoCommand.DISPINFO);
660 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
661 connector.sendQuery(NuvoEnum.valueOf(source), NuvoCommand.DISPLINE);
662 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
663 } catch (NuvoException | InterruptedException e) {
664 logger.debug("Error Querying Source data: {}", e.getMessage());
668 // Query all active zones to get their current status and eq configuration
669 activeZones.forEach(zoneNum -> {
671 connector.sendQuery(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.STATUS);
672 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
673 connector.sendCfgCommand(NuvoEnum.valueOf(ZONE + zoneNum), NuvoCommand.EQ_QUERY, BLANK);
674 Thread.sleep(SLEEP_BETWEEN_CMD_MS);
675 } catch (NuvoException | InterruptedException e) {
676 logger.debug("Error Querying Zone data: {}", e.getMessage());
680 List<StateOption> sourceStateOptions = new ArrayList<>();
681 sourceLabels.keySet().forEach(key -> {
682 sourceStateOptions.add(new StateOption(key, sourceLabels.get(key)));
685 // Put the source labels on all active zones
686 activeZones.forEach(zoneNum -> {
687 stateDescriptionProvider.setStateOptions(
688 new ChannelUID(getThing().getUID(),
689 ZONE.toLowerCase() + zoneNum + CHANNEL_DELIMIT + CHANNEL_TYPE_SOURCE),
692 } catch (NuvoException e) {
693 logger.debug("Error polling status from Nuvo: {}", e.getMessage());
700 * Cancel the reconnection job
702 private void cancelReconnectJob() {
703 ScheduledFuture<?> reconnectJob = this.reconnectJob;
704 if (reconnectJob != null) {
705 reconnectJob.cancel(true);
706 this.reconnectJob = null;
711 * Schedule the polling job
713 private void schedulePollingJob() {
717 logger.debug("MPS4 doesn't support polling");
720 logger.debug("Schedule polling job");
723 // when the Nuvo amp is off, this will keep the connection (esp Serial over IP) alive and detect if the
724 // connection goes down
725 pollingJob = scheduler.scheduleWithFixedDelay(() -> {
726 if (connector.isConnected()) {
727 logger.debug("Polling the component for updated status...");
729 synchronized (sequenceLock) {
731 connector.sendCommand(NuvoCommand.GET_CONTROLLER_VERSION);
732 } catch (NuvoException e) {
733 logger.debug("Polling error: {}", e.getMessage());
736 // if the last event received was more than 1.25 intervals ago,
737 // the component is not responding even though the connection is still good
738 if ((System.currentTimeMillis() - lastEventReceived) > (POLLING_INTERVAL_SEC * 1.25 * 1000)) {
739 logger.debug("Component not responding to status requests");
740 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
741 "Component not responding to status requests");
743 scheduleReconnectJob();
747 }, INITIAL_POLLING_DELAY_SEC, POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
751 * Cancel the polling job
753 private void cancelPollingJob() {
754 ScheduledFuture<?> pollingJob = this.pollingJob;
755 if (pollingJob != null) {
756 pollingJob.cancel(true);
757 this.pollingJob = null;
762 * Schedule the clock sync job
764 private void scheduleClockSyncJob() {
765 logger.debug("Schedule clock sync job");
766 cancelClockSyncJob();
767 clockSyncJob = scheduler.scheduleWithFixedDelay(() -> {
768 if (this.isGConcerto) {
770 connector.sendCommand(NuvoCommand.CFGTIME.getValue() + DATE_FORMAT.format(new Date()));
771 } catch (NuvoException e) {
772 logger.debug("Error syncing clock: {}", e.getMessage());
775 this.cancelClockSyncJob();
777 }, INITIAL_CLOCK_SYNC_DELAY_SEC, CLOCK_SYNC_INTERVAL_SEC, TimeUnit.SECONDS);
781 * Cancel the clock sync job
783 private void cancelClockSyncJob() {
784 ScheduledFuture<?> clockSyncJob = this.clockSyncJob;
785 if (clockSyncJob != null) {
786 clockSyncJob.cancel(true);
787 this.clockSyncJob = null;
792 * Update the state of a channel
794 * @param target the channel group
795 * @param channelType the channel group item
796 * @param value the value to be updated
798 private void updateChannelState(NuvoEnum target, String channelType, String value) {
799 String channel = target.name().toLowerCase() + CHANNEL_DELIMIT + channelType;
801 if (!isLinked(channel)) {
805 State state = UnDefType.UNDEF;
807 if (UNDEF.equals(value)) {
808 updateState(channel, state);
812 switch (channelType) {
813 case CHANNEL_TYPE_POWER:
814 case CHANNEL_TYPE_MUTE:
815 case CHANNEL_TYPE_DND:
816 case CHANNEL_TYPE_PARTY:
817 case CHANNEL_TYPE_ALLMUTE:
818 case CHANNEL_TYPE_PAGE:
819 case CHANNEL_TYPE_LOUDNESS:
820 state = ON.equals(value) ? OnOffType.ON : OnOffType.OFF;
822 case CHANNEL_TYPE_LOCK:
823 state = ON.equals(value) ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
825 case CHANNEL_TYPE_SOURCE:
826 case CHANNEL_TYPE_TREBLE:
827 case CHANNEL_TYPE_BASS:
828 case CHANNEL_TYPE_BALANCE:
829 state = new DecimalType(value);
831 case CHANNEL_TYPE_VOLUME:
832 int volume = Integer.parseInt(value);
833 long volumePct = Math
834 .round((double) (MAX_VOLUME - volume) / (double) (MAX_VOLUME - MIN_VOLUME) * 100.0);
835 state = new PercentType(BigDecimal.valueOf(volumePct));
837 case CHANNEL_DISPLAY_LINE1:
838 case CHANNEL_DISPLAY_LINE2:
839 case CHANNEL_DISPLAY_LINE3:
840 case CHANNEL_DISPLAY_LINE4:
841 case CHANNEL_BUTTON_PRESS:
842 state = new StringType(value);
844 case CHANNEL_PLAY_MODE:
845 state = new StringType(NuvoStatusCodes.PLAY_MODE.get(value));
847 case CHANNEL_TRACK_LENGTH:
848 case CHANNEL_TRACK_POSITION:
849 state = new QuantityType<Time>(Integer.parseInt(value) / 10, NuvoHandler.API_SECOND_UNIT);
854 updateState(channel, state);
858 * Handle a button press from a UI Player item
860 * @param target the nuvo zone to receive the command
861 * @param command the button press command to send to the zone
863 private void handleControlCommand(NuvoEnum target, Command command) throws NuvoException {
864 if (command instanceof PlayPauseType) {
865 connector.sendCommand(target, NuvoCommand.PLAYPAUSE);
866 } else if (command instanceof NextPreviousType) {
867 if (command == NextPreviousType.NEXT) {
868 connector.sendCommand(target, NuvoCommand.NEXT);
869 } else if (command == NextPreviousType.PREVIOUS) {
870 connector.sendCommand(target, NuvoCommand.PREV);
873 logger.warn("Unknown control command: {}", command);