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.communication;
15 import static org.openhab.binding.nuvo.internal.NuvoBindingConstants.*;
17 import java.io.IOException;
18 import java.io.InputStream;
19 import java.io.OutputStream;
20 import java.nio.charset.StandardCharsets;
21 import java.util.ArrayList;
22 import java.util.List;
23 import java.util.regex.Matcher;
24 import java.util.regex.Pattern;
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.openhab.binding.nuvo.internal.NuvoException;
29 import org.slf4j.Logger;
30 import org.slf4j.LoggerFactory;
33 * Abstract class for communicating with the Nuvo device
35 * @author Laurent Garnier - Initial contribution
36 * @author Michael Lobstein - Adapted for the Nuvo binding
39 public abstract class NuvoConnector {
40 private static final String COMMAND_OK = "#OK";
41 private static final String BEGIN_CMD = "*";
42 private static final String END_CMD = "\r";
43 private static final String QUERY = "?";
44 private static final String VER_STR_E6 = "#VER\"NV-E6G";
45 private static final String VER_STR_GC = "#VER\"NV-I8G";
46 private static final String ALL_OFF = "#ALLOFF";
47 private static final String MUTE = "#MUTE";
48 private static final String PAGE = "#PAGE";
49 private static final String RESTART = "#RESTART\"NuVoNet\"";
50 private static final String PING = "#PING";
51 private static final String PING_RESPONSE = "PING";
53 private static final byte[] WAKE_STR = "\r".getBytes(StandardCharsets.US_ASCII);
55 private static final Pattern SRC_PATTERN = Pattern.compile("^#S(\\d{1})(.*)$");
56 private static final Pattern ZONE_PATTERN = Pattern.compile("^#Z(\\d{1,2}),(.*)$");
57 private static final Pattern ZONE_SOURCE_PATTERN = Pattern.compile("^#Z(\\d{1,2})S(\\d{1})(.*)$");
58 private static final Pattern NN_MENUREQ_PATTERN = Pattern.compile("^#Z(\\d{1,2})S(\\d{1})MENUREQ(.*)$");
59 private static final Pattern NN_BUTTON_PATTERN = Pattern.compile("^#Z(\\d{1,2})S(\\d{1})BUTTON(.*)$");
60 private static final Pattern NN_BUTTONTWO_PATTERN = Pattern.compile("^#Z(\\d{1,2})S(\\d{1})BUTTONTWO(.*)$");
62 private static final Pattern ZONE_CFG_PATTERN = Pattern.compile("^#ZCFG(\\d{1,2}),(.*)$");
64 // S2ALBUMARTREQ0x620FD879,80,80,2,0x00C0C0C0,0,0,0,0,1
65 private static final Pattern NN_ALBUM_ART_REQ = Pattern.compile("^#S(\\d{1})ALBUMARTREQ(.*)$");
67 // S2ALBUMARTFRAGREQ0x620FD879,0,750
68 private static final Pattern NN_ALBUM_ART_FRAG_REQ = Pattern.compile("^#S(\\d{1})ALBUMARTFRAGREQ(.*)$");
70 // S6FAVORITE0x000003E8
71 private static final Pattern NN_FAVORITE_PATTERN = Pattern.compile("^#S(\\d{1})FAVORITE0x(.*)$");
73 private final Logger logger = LoggerFactory.getLogger(NuvoConnector.class);
75 protected static final String COMMAND_ERROR = "#?";
77 /** The output stream */
78 protected @Nullable OutputStream dataOut;
80 /** The input stream */
81 protected @Nullable InputStream dataIn;
83 /** true if the connection is established, false if not */
84 private boolean connected;
86 private @Nullable Thread readerThread;
88 private List<NuvoMessageEventListener> listeners = new ArrayList<>();
90 private boolean isEssentia = true;
91 private boolean isAnyOhNuvoNet = false;
94 * Get whether the connection is established or not
96 * @return true if the connection is established
98 public boolean isConnected() {
103 * Set whether the connection is established or not
105 * @param connected true if the connection is established
107 protected void setConnected(boolean connected) {
108 this.connected = connected;
112 * Tell the connector if the device is an Essentia G or not
114 * @param true if the device is an Essentia G
116 public void setEssentia(boolean isEssentia) {
117 this.isEssentia = isEssentia;
121 * Tell the connector to listen for NuvoNet source messages
123 * @param true if any sources are configured as openHAB NuvoNet sources
125 public void setAnyOhNuvoNet(boolean isAnyOhNuvoNet) {
126 this.isAnyOhNuvoNet = isAnyOhNuvoNet;
130 * Set the thread that handles the feedback messages
132 * @param readerThread the thread
134 protected void setReaderThread(Thread readerThread) {
135 this.readerThread = readerThread;
139 * Open the connection with the Nuvo device
141 * @throws NuvoException - In case of any problem
143 public abstract void open() throws NuvoException;
146 * Close the connection with the Nuvo device
148 public abstract void close();
151 * Stop the thread that handles the feedback messages and close the opened input and output streams
153 protected void cleanup() {
154 Thread readerThread = this.readerThread;
155 OutputStream dataOut = this.dataOut;
156 if (dataOut != null) {
159 } catch (IOException e) {
160 logger.debug("Error closing dataOut: {}", e.getMessage());
164 InputStream dataIn = this.dataIn;
165 if (dataIn != null) {
168 } catch (IOException e) {
169 logger.debug("Error closing dataIn: {}", e.getMessage());
173 if (readerThread != null) {
174 readerThread.interrupt();
175 this.readerThread = null;
177 readerThread.join(3000);
178 } catch (InterruptedException e) {
179 logger.warn("Error joining readerThread: {}", e.getMessage());
185 * Reads some number of bytes from the input stream and stores them into the buffer array b. The number of bytes
186 * actually read is returned as an integer.
188 * @param dataBuffer the buffer into which the data is read.
190 * @return the total number of bytes read into the buffer, or -1 if there is no more data because the end of the
191 * stream has been reached.
193 * @throws NuvoException - If the input stream is null, if the first byte cannot be read for any reason
194 * other than the end of the file, if the input stream has been closed, or if some other I/O error
197 protected int readInput(byte[] dataBuffer) throws NuvoException {
198 InputStream dataIn = this.dataIn;
199 if (dataIn == null) {
200 throw new NuvoException("readInput failed: input stream is null");
203 return dataIn.read(dataBuffer);
204 } catch (IOException e) {
205 throw new NuvoException("readInput failed: " + e.getMessage(), e);
210 * Request the Nuvo controller to execute an inquiry command
212 * @param zone the zone for which the command is to be run
213 * @param cmd the command to execute
215 * @throws NuvoException - In case of any problem
217 public void sendQuery(NuvoEnum zone, NuvoCommand cmd) throws NuvoException {
218 sendCommand(zone.getId() + cmd.getValue() + QUERY);
222 * Request the Nuvo controller to execute a command for a zone that takes no arguments (ie power on, power off,
225 * @param zone the zone for which the command is to be run
226 * @param cmd the command to execute
228 * @throws NuvoException - In case of any problem
230 public void sendCommand(NuvoEnum zone, NuvoCommand cmd) throws NuvoException {
231 sendCommand(zone.getId() + cmd.getValue());
235 * Request the Nuvo controller to execute a command for a zone and pass in a value
237 * @param zone the zone for which the command is to be run
238 * @param cmd the command to execute
239 * @param value the string value to consider for volume, source, etc.
241 * @throws NuvoException - In case of any problem
243 public void sendCommand(NuvoEnum zone, NuvoCommand cmd, @Nullable String value) throws NuvoException {
244 sendCommand(zone.getId() + cmd.getValue() + value);
248 * Request the Nuvo controller to execute a configuration command for a zone and pass in a value
250 * @param zone the zone for which the command is to be run
251 * @param cmd the command to execute
252 * @param value the string value to consider for bass, treble, balance, etc.
254 * @throws NuvoException - In case of any problem
256 public void sendCfgCommand(NuvoEnum zone, NuvoCommand cmd, @Nullable String value) throws NuvoException {
257 sendCommand(zone.getConfigId() + cmd.getValue() + value);
261 * Request the Nuvo controller to execute a system command the does not specify a zone or value
263 * @param cmd the command to execute
265 * @throws NuvoException - In case of any problem
267 public void sendCommand(NuvoCommand cmd) throws NuvoException {
268 sendCommand(cmd.getValue());
272 * Request the Nuvo controller to execute a raw command string
274 * @param command the command string to run
276 * @throws NuvoException - In case of any problem
278 public void sendCommand(@Nullable String command) throws NuvoException {
279 String messageStr = BEGIN_CMD + command + END_CMD;
281 logger.debug("sending command: {}", messageStr);
283 OutputStream dataOut = this.dataOut;
284 if (dataOut == null) {
285 throw new NuvoException("Send command \"" + messageStr + "\" failed: output stream is null");
288 // Essentia G needs time to wake up when in standby mode
289 // I don't want to track that in the binding, so just do this always
290 if (this.isEssentia) {
291 dataOut.write(WAKE_STR);
294 dataOut.write(messageStr.getBytes(StandardCharsets.US_ASCII));
296 } catch (IOException e) {
297 throw new NuvoException("Send command \"" + command + "\" failed: " + e.getMessage(), e);
302 * Add a listener to the list of listeners to be notified with events
304 * @param listener the listener
306 public void addEventListener(NuvoMessageEventListener listener) {
307 listeners.add(listener);
311 * Remove a listener from the list of listeners to be notified with events
313 * @param listener the listener
315 public void removeEventListener(NuvoMessageEventListener listener) {
316 listeners.remove(listener);
320 * Analyze an incoming message and dispatch corresponding (type, zone, src, value) to the event listeners
322 * @param incomingMessage the received message
324 public void handleIncomingMessage(byte[] incomingMessage) {
325 String message = new String(incomingMessage, StandardCharsets.US_ASCII).trim();
327 logger.debug("handleIncomingMessage: {}", message);
329 if (COMMAND_ERROR.equals(message) || COMMAND_OK.equals(message)) {
334 if (message.contains(PING)) {
336 sendCommand(PING_RESPONSE);
337 } catch (NuvoException e) {
338 logger.debug("Error sending response to PING command");
340 dispatchKeyValue(TYPE_PING, BLANK);
344 if (RESTART.equals(message)) {
345 dispatchKeyValue(TYPE_RESTART, BLANK);
349 if (message.contains(VER_STR_E6) || message.contains(VER_STR_GC)) {
350 // example: #VER"NV-E6G FWv2.66 HWv0"
351 // split on " and return the version number
352 dispatchKeyValue(TYPE_VERSION, message.split("\"")[1]);
356 if (message.equals(ALL_OFF)) {
357 dispatchKeyValue(TYPE_ALLOFF, BLANK);
361 if (message.contains(MUTE)) {
362 dispatchKeyValue(TYPE_ALLMUTE, message.substring(message.length() - 1));
366 if (message.contains(PAGE)) {
367 dispatchKeyValue(TYPE_PAGE, message.substring(message.length() - 1));
373 if (isAnyOhNuvoNet) {
374 // Amp controller sent a NuvoNet album art request
375 matcher = NN_ALBUM_ART_REQ.matcher(message);
376 if (matcher.find()) {
377 dispatchKeyValue(TYPE_NN_ALBUM_ART_REQ, BLANK, matcher.group(1), matcher.group(2));
381 // Amp controller sent a NuvoNet album art fragment request
382 matcher = NN_ALBUM_ART_FRAG_REQ.matcher(message);
383 if (matcher.find()) {
384 dispatchKeyValue(TYPE_NN_ALBUM_ART_FRAG_REQ, BLANK, matcher.group(1), matcher.group(2));
388 // Amp controller sent a request for a NuvoNet source to play a favorite
389 matcher = NN_FAVORITE_PATTERN.matcher(message);
390 if (matcher.find()) {
391 dispatchKeyValue(TYPE_NN_FAVORITE_REQ, BLANK, matcher.group(1), matcher.group(2));
396 // Amp controller sent a source update ie: #S2DISPINFO,DUR3380,POS3090,STATUS2
397 // or #S2DISPLINE1,"1 of 17"
398 matcher = SRC_PATTERN.matcher(message);
399 if (matcher.find()) {
400 dispatchKeyValue(TYPE_SOURCE_UPDATE, BLANK, matcher.group(1), matcher.group(2));
404 // Amp controller sent a zone update ie: #Z11,ON,SRC3,VOL63,DND0,LOCK0
405 matcher = ZONE_PATTERN.matcher(message);
406 if (matcher.find()) {
407 dispatchKeyValue(TYPE_ZONE_UPDATE, matcher.group(1), BLANK, matcher.group(2));
411 if (isAnyOhNuvoNet) {
412 // Amp controller sent a NuvoNet zone/source BUTTONTWO press event ie: #Z11S3BUTTONTWO4,2,0,0,0
413 matcher = NN_BUTTONTWO_PATTERN.matcher(message);
414 if (matcher.find()) {
415 // redundant - ignore
419 // Amp controller sent a NuvoNet zone/source BUTTON press event ie: #Z4S6BUTTON1,1,0xFFFFFFFF,1,3
420 matcher = NN_BUTTON_PATTERN.matcher(message);
421 if (matcher.find()) {
422 // pull out the remainder of the message: button #, action, menuid, itemid, itemidx
423 String[] buttonSplit = matcher.group(3).split(COMMA);
425 // second field is button action, only send DOWNUP (0) or DOWN (1), ignore UP (2)
426 if (ZERO.equals(buttonSplit[1]) || ONE.equals(buttonSplit[1])) {
427 // a button in a menu was pressed, send 'menuid,itemidx'
428 if (!ZERO.equals(buttonSplit[2])) {
429 dispatchKeyValue(TYPE_NN_MENU_ITEM_SELECTED, matcher.group(1), matcher.group(2),
430 buttonSplit[2] + COMMA + buttonSplit[3]);
432 // send the button # in the event, don't send extra fields menuid, itemid, etc..
433 dispatchKeyValue(TYPE_NN_BUTTON, matcher.group(1), matcher.group(2), buttonSplit[0]);
439 // Amp controller sent a NuvoNet zone/source menu request event ie: #Z2S6MENUREQ0x0000000B,1,0,0
440 matcher = NN_MENUREQ_PATTERN.matcher(message);
441 if (matcher.find()) {
442 dispatchKeyValue(TYPE_NN_MENUREQ, matcher.group(1), matcher.group(2), matcher.group(3));
447 // Amp controller sent a zone/source button press event ie: #Z11S3PLAYPAUSE
448 matcher = ZONE_SOURCE_PATTERN.matcher(message);
449 if (matcher.find()) {
450 dispatchKeyValue(TYPE_ZONE_SOURCE_BUTTON, matcher.group(1), matcher.group(2), matcher.group(3));
454 // Amp controller sent a zone configuration response ie: #ZCFG1,BASS1,TREB-2,BALR2,LOUDCMP1
455 matcher = ZONE_CFG_PATTERN.matcher(message);
456 if (matcher.find()) {
457 // pull out the zone id and the remainder of the message
458 dispatchKeyValue(TYPE_ZONE_CONFIG, matcher.group(1), BLANK, matcher.group(2));
462 logger.debug("unhandled message: {}", message);
466 * Dispatch a system level event without zone or src to the event listeners
468 * @param type the type
469 * @param value the value
471 private void dispatchKeyValue(String type, String value) {
472 dispatchKeyValue(type, BLANK, BLANK, value);
476 * Dispatch an event (type, zone, src, value) to the event listeners
478 * @param type the type
479 * @param zone the zone id
480 * @param src the source id
481 * @param value the value
483 private void dispatchKeyValue(String type, String zone, String src, String value) {
484 NuvoMessageEvent event = new NuvoMessageEvent(this, type, zone, src, value);
485 listeners.forEach(l -> l.onNewMessageEvent(event));