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.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_BUTTON_PATTERN = Pattern.compile("^#Z(\\d{1,2})S(\\d{1})(.*)$");
58 private static final Pattern ZONE_MENUREQ_PATTERN = Pattern.compile("^#Z(\\d{1,2})S(\\d{1})MENUREQ(.*)$");
59 private static final Pattern ZONE_BUTTON2_PATTERN = Pattern.compile("^#Z(\\d{1,2})S(\\d{1})BUTTON(.*)$");
60 private static final Pattern ZONE_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 ALBUM_ART_REQ = Pattern.compile("^#S(\\d{1})ALBUMARTREQ(.*)$");
67 // S2ALBUMARTFRAGREQ0x620FD879,0,750
68 private static final Pattern ALBUM_ART_FRAG_REQ = Pattern.compile("^#S(\\d{1})ALBUMARTFRAGREQ(.*)$");
70 // S6FAVORITE0x000003E8
71 private static final Pattern 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;
93 * Get whether the connection is established or not
95 * @return true if the connection is established
97 public boolean isConnected() {
102 * Set whether the connection is established or not
104 * @param connected true if the connection is established
106 protected void setConnected(boolean connected) {
107 this.connected = connected;
111 * Tell the connector if the device is an Essentia G or not
113 * @param true if the device is an Essentia G
115 public void setEssentia(boolean isEssentia) {
116 this.isEssentia = isEssentia;
120 * Set the thread that handles the feedback messages
122 * @param readerThread the thread
124 protected void setReaderThread(Thread readerThread) {
125 this.readerThread = readerThread;
129 * Open the connection with the Nuvo device
131 * @throws NuvoException - In case of any problem
133 public abstract void open() throws NuvoException;
136 * Close the connection with the Nuvo device
138 public abstract void close();
141 * Stop the thread that handles the feedback messages and close the opened input and output streams
143 protected void cleanup() {
144 Thread readerThread = this.readerThread;
145 OutputStream dataOut = this.dataOut;
146 if (dataOut != null) {
149 } catch (IOException e) {
150 logger.debug("Error closing dataOut: {}", e.getMessage());
154 InputStream dataIn = this.dataIn;
155 if (dataIn != null) {
158 } catch (IOException e) {
159 logger.debug("Error closing dataIn: {}", e.getMessage());
163 if (readerThread != null) {
164 readerThread.interrupt();
165 this.readerThread = null;
167 readerThread.join(3000);
168 } catch (InterruptedException e) {
169 logger.warn("Error joining readerThread: {}", e.getMessage());
175 * Reads some number of bytes from the input stream and stores them into the buffer array b. The number of bytes
176 * actually read is returned as an integer.
178 * @param dataBuffer the buffer into which the data is read.
180 * @return the total number of bytes read into the buffer, or -1 if there is no more data because the end of the
181 * stream has been reached.
183 * @throws NuvoException - If the input stream is null, if the first byte cannot be read for any reason
184 * other than the end of the file, if the input stream has been closed, or if some other I/O error
187 protected int readInput(byte[] dataBuffer) throws NuvoException {
188 InputStream dataIn = this.dataIn;
189 if (dataIn == null) {
190 throw new NuvoException("readInput failed: input stream is null");
193 return dataIn.read(dataBuffer);
194 } catch (IOException e) {
195 throw new NuvoException("readInput failed: " + e.getMessage(), e);
200 * Request the Nuvo controller to execute an inquiry command
202 * @param zone the zone for which the command is to be run
203 * @param cmd the command to execute
205 * @throws NuvoException - In case of any problem
207 public void sendQuery(NuvoEnum zone, NuvoCommand cmd) throws NuvoException {
208 sendCommand(zone.getId() + cmd.getValue() + QUERY);
212 * Request the Nuvo controller to execute a command for a zone that takes no arguments (ie power on, power off,
215 * @param zone the zone for which the command is to be run
216 * @param cmd the command to execute
218 * @throws NuvoException - In case of any problem
220 public void sendCommand(NuvoEnum zone, NuvoCommand cmd) throws NuvoException {
221 sendCommand(zone.getId() + cmd.getValue());
225 * Request the Nuvo controller to execute a command for a zone and pass in a value
227 * @param zone the zone for which the command is to be run
228 * @param cmd the command to execute
229 * @param value the string value to consider for volume, source, etc.
231 * @throws NuvoException - In case of any problem
233 public void sendCommand(NuvoEnum zone, NuvoCommand cmd, @Nullable String value) throws NuvoException {
234 sendCommand(zone.getId() + cmd.getValue() + value);
238 * Request the Nuvo controller to execute a configuration command for a zone and pass in a value
240 * @param zone the zone for which the command is to be run
241 * @param cmd the command to execute
242 * @param value the string value to consider for bass, treble, balance, etc.
244 * @throws NuvoException - In case of any problem
246 public void sendCfgCommand(NuvoEnum zone, NuvoCommand cmd, @Nullable String value) throws NuvoException {
247 sendCommand(zone.getConfigId() + cmd.getValue() + value);
251 * Request the Nuvo controller to execute a system command the does not specify a zone or value
253 * @param cmd the command to execute
255 * @throws NuvoException - In case of any problem
257 public void sendCommand(NuvoCommand cmd) throws NuvoException {
258 sendCommand(cmd.getValue());
262 * Request the Nuvo controller to execute a raw command string
264 * @param command the command string to run
266 * @throws NuvoException - In case of any problem
268 public void sendCommand(@Nullable String command) throws NuvoException {
269 String messageStr = BEGIN_CMD + command + END_CMD;
271 logger.debug("sending command: {}", messageStr);
273 OutputStream dataOut = this.dataOut;
274 if (dataOut == null) {
275 throw new NuvoException("Send command \"" + messageStr + "\" failed: output stream is null");
278 // Essentia G needs time to wake up when in standby mode
279 // I don't want to track that in the binding, so just do this always
280 if (this.isEssentia) {
281 dataOut.write(WAKE_STR);
284 dataOut.write(messageStr.getBytes(StandardCharsets.US_ASCII));
286 } catch (IOException e) {
287 throw new NuvoException("Send command \"" + command + "\" failed: " + e.getMessage(), e);
292 * Add a listener to the list of listeners to be notified with events
294 * @param listener the listener
296 public void addEventListener(NuvoMessageEventListener listener) {
297 listeners.add(listener);
301 * Remove a listener from the list of listeners to be notified with events
303 * @param listener the listener
305 public void removeEventListener(NuvoMessageEventListener listener) {
306 listeners.remove(listener);
310 * Analyze an incoming message and dispatch corresponding (type, key, value) to the event listeners
312 * @param incomingMessage the received message
314 public void handleIncomingMessage(byte[] incomingMessage) {
315 String message = new String(incomingMessage, StandardCharsets.US_ASCII).trim();
317 logger.debug("handleIncomingMessage: {}", message);
319 if (COMMAND_ERROR.equals(message) || COMMAND_OK.equals(message)) {
324 if (message.contains(PING)) {
326 sendCommand(PING_RESPONSE);
327 } catch (NuvoException e) {
328 logger.debug("Error sending response to PING command");
330 dispatchKeyValue(TYPE_PING, BLANK, BLANK);
334 if (RESTART.equals(message)) {
335 dispatchKeyValue(TYPE_RESTART, BLANK, BLANK);
339 if (message.contains(VER_STR_E6) || message.contains(VER_STR_GC)) {
340 // example: #VER"NV-E6G FWv2.66 HWv0"
341 // split on " and return the version number
342 dispatchKeyValue(TYPE_VERSION, "", message.split("\"")[1]);
346 if (message.equals(ALL_OFF)) {
347 dispatchKeyValue(TYPE_ALLOFF, BLANK, BLANK);
351 if (message.contains(MUTE)) {
352 dispatchKeyValue(TYPE_ALLMUTE, BLANK, message.substring(message.length() - 1));
356 if (message.contains(PAGE)) {
357 dispatchKeyValue(TYPE_PAGE, BLANK, message.substring(message.length() - 1));
361 // Amp controller sent an album art request
362 Matcher matcher = ALBUM_ART_REQ.matcher(message);
363 if (matcher.find()) {
364 // pull out the source id and the remainder of the message
365 dispatchKeyValue(TYPE_ALBUM_ART_REQ, matcher.group(1), matcher.group(2));
369 // Amp controller sent an album art fragment request
370 matcher = ALBUM_ART_FRAG_REQ.matcher(message);
371 if (matcher.find()) {
372 // pull out the source id and the remainder of the message
373 dispatchKeyValue(TYPE_ALBUM_ART_FRAG_REQ, matcher.group(1), matcher.group(2));
377 // Amp controller sent a request to play a favorite
378 matcher = FAVORITE_PATTERN.matcher(message);
379 if (matcher.find()) {
380 // pull out the source id and the remainder of the message
381 dispatchKeyValue(TYPE_FAVORITE_REQ, matcher.group(1), matcher.group(2));
385 // Amp controller sent a source update ie: #S2DISPINFO,DUR3380,POS3090,STATUS2
386 // or #S2DISPLINE1,"1 of 17"
387 matcher = SRC_PATTERN.matcher(message);
388 if (matcher.find()) {
389 // pull out the source id and the remainder of the message
390 dispatchKeyValue(TYPE_SOURCE_UPDATE, matcher.group(1), matcher.group(2));
394 // Amp controller sent a zone update ie: #Z11,ON,SRC3,VOL63,DND0,LOCK0
395 matcher = ZONE_PATTERN.matcher(message);
396 if (matcher.find()) {
397 // pull out the zone id and the remainder of the message
398 dispatchKeyValue(TYPE_ZONE_UPDATE, matcher.group(1), matcher.group(2));
402 // Amp controller sent a zone BUTTONTWO press event ie: #Z11S3BUTTONTWO4,2,0,0,0
403 matcher = ZONE_BUTTONTWO_PATTERN.matcher(message);
404 if (matcher.find()) {
405 // redundant - ignore
409 // Amp controller sent a zone BUTTON press event ie: #Z4S6BUTTON1,1,0xFFFFFFFF,1,3
410 matcher = ZONE_BUTTON2_PATTERN.matcher(message);
411 if (matcher.find()) {
412 // pull out the remainder of the message: button #, action, menuid, itemid, itemidx
413 String[] buttonSplit = matcher.group(3).split(COMMA);
415 // second field is button action, only send DOWNUP (0) or DOWN (1), ignore UP (2)
416 if (ZERO.equals(buttonSplit[1]) || ONE.equals(buttonSplit[1])) {
417 // a button in a menu was pressed, send SxZy,menuid,itemidx
418 if (!ZERO.equals(buttonSplit[2])) {
419 dispatchKeyValue(TYPE_MENU_ITEM_SELECTED, matcher.group(2), SRC_KEY + matcher.group(2) + ZONE_KEY
420 + matcher.group(1) + COMMA + buttonSplit[2] + COMMA + buttonSplit[3]);
422 // send the button # in the event, don't send extra fields menuid, itemid, etc..
423 dispatchKeyValue(TYPE_ZONE_BUTTON2, matcher.group(2), buttonSplit[0]);
429 // Amp controller sent a menu request event ie: #Z2S6MENUREQ0x0000000B,1,0,0
430 matcher = ZONE_MENUREQ_PATTERN.matcher(message);
431 if (matcher.find()) {
432 // pull out the source id and send SxZy plus the remainder of the message
433 dispatchKeyValue(TYPE_ZONE_MENUREQ, matcher.group(2),
434 SRC_KEY + matcher.group(2) + ZONE_KEY + matcher.group(1) + COMMA + matcher.group(3));
438 // Amp controller sent a zone button press event ie: #Z11S3PLAYPAUSE
439 matcher = ZONE_BUTTON_PATTERN.matcher(message);
440 if (matcher.find()) {
441 // pull out the source id and the remainder of the message, ignore the zone id
442 dispatchKeyValue(TYPE_ZONE_BUTTON, matcher.group(2), matcher.group(3));
446 // Amp controller sent a zone configuration response ie: #ZCFG1,BASS1,TREB-2,BALR2,LOUDCMP1
447 matcher = ZONE_CFG_PATTERN.matcher(message);
448 if (matcher.find()) {
449 // pull out the zone id and the remainder of the message
450 dispatchKeyValue(TYPE_ZONE_CONFIG, matcher.group(1), matcher.group(2));
454 logger.debug("unhandled message: {}", message);
458 * Dispatch an event (type, key, value) to the event listeners
460 * @param type the type
462 * @param value the value
464 private void dispatchKeyValue(String type, String key, String value) {
465 NuvoMessageEvent event = new NuvoMessageEvent(this, type, key, value);
466 listeners.forEach(l -> l.onNewMessageEvent(event));