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 ALLOFF = "#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 Pattern SRC_PATTERN = Pattern.compile("^#S(\\d{1})(.*)$");
54 private static final Pattern ZONE_PATTERN = Pattern.compile("^#Z(\\d{1,2}),(.*)$");
55 private static final Pattern ZONE_SOURCE_PATTERN = Pattern.compile("^#Z(\\d{1,2})S(\\d{1})(.*)$");
56 private static final Pattern NN_MENUREQ_PATTERN = Pattern.compile("^#Z(\\d{1,2})S(\\d{1})MENUREQ(.*)$");
57 private static final Pattern NN_BUTTON_PATTERN = Pattern.compile("^#Z(\\d{1,2})S(\\d{1})BUTTON(.*)$");
58 private static final Pattern NN_BUTTONTWO_PATTERN = Pattern.compile("^#Z(\\d{1,2})S(\\d{1})BUTTONTWO(.*)$");
60 private static final Pattern ZONE_CFG_PATTERN = Pattern.compile("^#ZCFG(\\d{1,2}),(.*)$");
62 // S2ALBUMARTREQ0x620FD879,80,80,2,0x00C0C0C0,0,0,0,0,1
63 private static final Pattern NN_ALBUM_ART_REQ = Pattern.compile("^#S(\\d{1})ALBUMARTREQ(.*)$");
65 // S2ALBUMARTFRAGREQ0x620FD879,0,750
66 private static final Pattern NN_ALBUM_ART_FRAG_REQ = Pattern.compile("^#S(\\d{1})ALBUMARTFRAGREQ(.*)$");
68 // S6FAVORITE0x000003E8
69 private static final Pattern NN_FAVORITE_PATTERN = Pattern.compile("^#S(\\d{1})FAVORITE0x(.*)$");
71 private final Logger logger = LoggerFactory.getLogger(NuvoConnector.class);
73 protected static final String COMMAND_ERROR = "#?";
75 /** The output stream */
76 protected @Nullable OutputStream dataOut;
78 /** The input stream */
79 protected @Nullable InputStream dataIn;
81 /** true if the connection is established, false if not */
82 private boolean connected;
84 private @Nullable Thread readerThread;
86 private List<NuvoMessageEventListener> listeners = new ArrayList<>();
88 private boolean isEssentia = true;
89 private boolean isStandbyMode = true;
90 private boolean isAnyOhNuvoNet = false;
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 * Tell the connector to listen for NuvoNet source messages
122 * @param true if any sources are configured as openHAB NuvoNet sources
124 public void setAnyOhNuvoNet(boolean isAnyOhNuvoNet) {
125 this.isAnyOhNuvoNet = isAnyOhNuvoNet;
129 * Set the thread that handles the feedback messages
131 * @param readerThread the thread
133 protected void setReaderThread(Thread readerThread) {
134 this.readerThread = readerThread;
138 * Open the connection with the Nuvo device
140 * @throws NuvoException - In case of any problem
142 public abstract void open() throws NuvoException;
145 * Close the connection with the Nuvo device
147 public abstract void close();
150 * Stop the thread that handles the feedback messages and close the opened input and output streams
152 protected void cleanup() {
153 Thread readerThread = this.readerThread;
154 OutputStream dataOut = this.dataOut;
155 if (dataOut != null) {
158 } catch (IOException e) {
159 logger.debug("Error closing dataOut: {}", e.getMessage());
163 InputStream dataIn = this.dataIn;
164 if (dataIn != null) {
167 } catch (IOException e) {
168 logger.debug("Error closing dataIn: {}", e.getMessage());
172 if (readerThread != null) {
173 readerThread.interrupt();
174 this.readerThread = null;
176 readerThread.join(3000);
177 } catch (InterruptedException e) {
178 logger.warn("Error joining readerThread: {}", e.getMessage());
184 * Reads some number of bytes from the input stream and stores them into the buffer array b. The number of bytes
185 * actually read is returned as an integer.
187 * @param dataBuffer the buffer into which the data is read.
189 * @return the total number of bytes read into the buffer, or -1 if there is no more data because the end of the
190 * stream has been reached.
192 * @throws NuvoException - If the input stream is null, if the first byte cannot be read for any reason
193 * other than the end of the file, if the input stream has been closed, or if some other I/O error
196 protected int readInput(byte[] dataBuffer) throws NuvoException {
197 InputStream dataIn = this.dataIn;
198 if (dataIn == null) {
199 throw new NuvoException("readInput failed: input stream is null");
202 return dataIn.read(dataBuffer);
203 } catch (IOException e) {
204 throw new NuvoException("readInput failed: " + e.getMessage(), e);
209 * Request the Nuvo controller to execute an inquiry command
211 * @param zone the zone for which the command is to be run
212 * @param cmd the command to execute
214 * @throws NuvoException - In case of any problem
216 public void sendQuery(NuvoEnum zone, NuvoCommand cmd) throws NuvoException {
217 sendCommand(zone.getId() + cmd.getValue() + QUERY);
221 * Request the Nuvo controller to execute a command for a zone that takes no arguments (ie power on, power off,
224 * @param zone the zone for which the command is to be run
225 * @param cmd the command to execute
227 * @throws NuvoException - In case of any problem
229 public void sendCommand(NuvoEnum zone, NuvoCommand cmd) throws NuvoException {
230 sendCommand(zone.getId() + cmd.getValue());
234 * Request the Nuvo controller to execute a command for a zone and pass in a value
236 * @param zone the zone for which the command is to be run
237 * @param cmd the command to execute
238 * @param value the string value to consider for volume, source, etc.
240 * @throws NuvoException - In case of any problem
242 public void sendCommand(NuvoEnum zone, NuvoCommand cmd, @Nullable String value) throws NuvoException {
243 sendCommand(zone.getId() + cmd.getValue() + value);
247 * Request the Nuvo controller to execute a configuration command for a zone and pass in a value
249 * @param zone the zone for which the command is to be run
250 * @param cmd the command to execute
251 * @param value the string value to consider for bass, treble, balance, etc.
253 * @throws NuvoException - In case of any problem
255 public void sendCfgCommand(NuvoEnum zone, NuvoCommand cmd, @Nullable String value) throws NuvoException {
256 sendCommand(zone.getConfigId() + cmd.getValue() + value);
260 * Request the Nuvo controller to execute a system command the does not specify a zone or value
262 * @param cmd the command to execute
264 * @throws NuvoException - In case of any problem
266 public void sendCommand(NuvoCommand cmd) throws NuvoException {
267 sendCommand(cmd.getValue());
271 * Request the Nuvo controller to execute a raw command string
273 * @param command the command string to run
275 * @throws NuvoException - In case of any problem
277 public void sendCommand(String command) throws NuvoException {
278 String messageStr = BEGIN_CMD + command + END_CMD;
280 logger.debug("sending command: {}", messageStr);
282 OutputStream dataOut = this.dataOut;
283 if (dataOut == null) {
284 throw new NuvoException("Send command \"" + messageStr + "\" failed: output stream is null");
287 // The Essentia G needs to be awake before processing ON commands when in standby mode
288 // Repeat the command being sent to force it awake
289 // Sending carriage returns as described in the documentation was not working
290 if (isEssentia && isStandbyMode
291 && (command.endsWith(ON) || NuvoCommand.PAGE_ON.getValue().equals(command))) {
292 messageStr += messageStr;
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(ALLOFF)) {
357 isStandbyMode = true;
358 dispatchKeyValue(TYPE_ALLOFF, BLANK);
362 if (message.contains(MUTE)) {
363 dispatchKeyValue(TYPE_ALLMUTE, message.substring(message.length() - 1));
367 if (message.contains(PAGE)) {
368 dispatchKeyValue(TYPE_PAGE, message.substring(message.length() - 1));
374 if (isAnyOhNuvoNet) {
375 // Amp controller sent a NuvoNet album art request
376 matcher = NN_ALBUM_ART_REQ.matcher(message);
377 if (matcher.find()) {
378 dispatchKeyValue(TYPE_NN_ALBUM_ART_REQ, BLANK, matcher.group(1), matcher.group(2));
382 // Amp controller sent a NuvoNet album art fragment request
383 matcher = NN_ALBUM_ART_FRAG_REQ.matcher(message);
384 if (matcher.find()) {
385 dispatchKeyValue(TYPE_NN_ALBUM_ART_FRAG_REQ, BLANK, matcher.group(1), matcher.group(2));
389 // Amp controller sent a request for a NuvoNet source to play a favorite
390 matcher = NN_FAVORITE_PATTERN.matcher(message);
391 if (matcher.find()) {
392 dispatchKeyValue(TYPE_NN_FAVORITE_REQ, BLANK, matcher.group(1), matcher.group(2));
397 // Amp controller sent a source update ie: #S2DISPINFO,DUR3380,POS3090,STATUS2
398 // or #S2DISPLINE1,"1 of 17"
399 matcher = SRC_PATTERN.matcher(message);
400 if (matcher.find()) {
401 dispatchKeyValue(TYPE_SOURCE_UPDATE, BLANK, matcher.group(1), matcher.group(2));
405 // Amp controller sent a zone update ie: #Z11,ON,SRC3,VOL63,DND0,LOCK0
406 matcher = ZONE_PATTERN.matcher(message);
407 if (matcher.find()) {
408 dispatchKeyValue(TYPE_ZONE_UPDATE, matcher.group(1), BLANK, matcher.group(2));
409 if (message.contains(ON)) {
410 isStandbyMode = false;
415 if (isAnyOhNuvoNet) {
416 // Amp controller sent a NuvoNet zone/source BUTTONTWO press event ie: #Z11S3BUTTONTWO4,2,0,0,0
417 matcher = NN_BUTTONTWO_PATTERN.matcher(message);
418 if (matcher.find()) {
419 // redundant - ignore
423 // Amp controller sent a NuvoNet zone/source BUTTON press event ie: #Z4S6BUTTON1,1,0xFFFFFFFF,1,3
424 matcher = NN_BUTTON_PATTERN.matcher(message);
425 if (matcher.find()) {
426 // pull out the remainder of the message: button #, action, menuid, itemid, itemidx
427 String[] buttonSplit = matcher.group(3).split(COMMA);
429 // second field is button action, only send DOWNUP (0) or DOWN (1), ignore UP (2)
430 if (ZERO.equals(buttonSplit[1]) || ONE.equals(buttonSplit[1])) {
431 // a button in a menu was pressed, send 'menuid,itemidx'
432 if (!ZERO.equals(buttonSplit[2])) {
433 dispatchKeyValue(TYPE_NN_MENU_ITEM_SELECTED, matcher.group(1), matcher.group(2),
434 buttonSplit[2] + COMMA + buttonSplit[3]);
436 // send the button # in the event, don't send extra fields menuid, itemid, etc..
437 dispatchKeyValue(TYPE_NN_BUTTON, matcher.group(1), matcher.group(2), buttonSplit[0]);
443 // Amp controller sent a NuvoNet zone/source menu request event ie: #Z2S6MENUREQ0x0000000B,1,0,0
444 matcher = NN_MENUREQ_PATTERN.matcher(message);
445 if (matcher.find()) {
446 dispatchKeyValue(TYPE_NN_MENUREQ, matcher.group(1), matcher.group(2), matcher.group(3));
451 // Amp controller sent a zone/source button press event ie: #Z11S3PLAYPAUSE
452 matcher = ZONE_SOURCE_PATTERN.matcher(message);
453 if (matcher.find()) {
454 dispatchKeyValue(TYPE_ZONE_SOURCE_BUTTON, matcher.group(1), matcher.group(2), matcher.group(3));
458 // Amp controller sent a zone configuration response ie: #ZCFG1,BASS1,TREB-2,BALR2,LOUDCMP1
459 matcher = ZONE_CFG_PATTERN.matcher(message);
460 if (matcher.find()) {
461 // pull out the zone id and the remainder of the message
462 dispatchKeyValue(TYPE_ZONE_CONFIG, matcher.group(1), BLANK, matcher.group(2));
466 logger.debug("unhandled message: {}", message);
470 * Dispatch a system level event without zone or src to the event listeners
472 * @param type the type
473 * @param value the value
475 private void dispatchKeyValue(String type, String value) {
476 dispatchKeyValue(type, BLANK, BLANK, value);
480 * Dispatch an event (type, zone, src, value) to the event listeners
482 * @param type the type
483 * @param zone the zone id
484 * @param src the source id
485 * @param value the value
487 private void dispatchKeyValue(String type, String zone, String src, String value) {
488 NuvoMessageEvent event = new NuvoMessageEvent(this, type, zone, src, value);
489 listeners.forEach(l -> l.onNewMessageEvent(event));