]> git.basschouten.com Git - openhab-addons.git/blob
6ce667192c7b78e74810d947e26f3f1e396b74f5
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.nuvo.internal.communication;
14
15 import static org.openhab.binding.nuvo.internal.NuvoBindingConstants.*;
16
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;
25
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;
31
32 /**
33  * Abstract class for communicating with the Nuvo device
34  *
35  * @author Laurent Garnier - Initial contribution
36  * @author Michael Lobstein - Adapted for the Nuvo binding
37  */
38 @NonNullByDefault
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";
52
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(.*)$");
59
60     private static final Pattern ZONE_CFG_PATTERN = Pattern.compile("^#ZCFG(\\d{1,2}),(.*)$");
61
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(.*)$");
64
65     // S2ALBUMARTFRAGREQ0x620FD879,0,750
66     private static final Pattern NN_ALBUM_ART_FRAG_REQ = Pattern.compile("^#S(\\d{1})ALBUMARTFRAGREQ(.*)$");
67
68     // S6FAVORITE0x000003E8
69     private static final Pattern NN_FAVORITE_PATTERN = Pattern.compile("^#S(\\d{1})FAVORITE0x(.*)$");
70
71     private final Logger logger = LoggerFactory.getLogger(NuvoConnector.class);
72
73     protected static final String COMMAND_ERROR = "#?";
74
75     /** The output stream */
76     protected @Nullable OutputStream dataOut;
77
78     /** The input stream */
79     protected @Nullable InputStream dataIn;
80
81     /** true if the connection is established, false if not */
82     private boolean connected;
83
84     private @Nullable Thread readerThread;
85
86     private List<NuvoMessageEventListener> listeners = new ArrayList<>();
87
88     private boolean isEssentia = true;
89     private boolean isStandbyMode = true;
90     private boolean isAnyOhNuvoNet = false;
91
92     /**
93      * Get whether the connection is established or not
94      *
95      * @return true if the connection is established
96      */
97     public boolean isConnected() {
98         return connected;
99     }
100
101     /**
102      * Set whether the connection is established or not
103      *
104      * @param connected true if the connection is established
105      */
106     protected void setConnected(boolean connected) {
107         this.connected = connected;
108     }
109
110     /**
111      * Tell the connector if the device is an Essentia G or not
112      *
113      * @param true if the device is an Essentia G
114      */
115     public void setEssentia(boolean isEssentia) {
116         this.isEssentia = isEssentia;
117     }
118
119     /**
120      * Tell the connector to listen for NuvoNet source messages
121      *
122      * @param true if any sources are configured as openHAB NuvoNet sources
123      */
124     public void setAnyOhNuvoNet(boolean isAnyOhNuvoNet) {
125         this.isAnyOhNuvoNet = isAnyOhNuvoNet;
126     }
127
128     /**
129      * Set the thread that handles the feedback messages
130      *
131      * @param readerThread the thread
132      */
133     protected void setReaderThread(Thread readerThread) {
134         this.readerThread = readerThread;
135     }
136
137     /**
138      * Open the connection with the Nuvo device
139      *
140      * @throws NuvoException - In case of any problem
141      */
142     public abstract void open() throws NuvoException;
143
144     /**
145      * Close the connection with the Nuvo device
146      */
147     public abstract void close();
148
149     /**
150      * Stop the thread that handles the feedback messages and close the opened input and output streams
151      */
152     protected void cleanup() {
153         Thread readerThread = this.readerThread;
154         OutputStream dataOut = this.dataOut;
155         if (dataOut != null) {
156             try {
157                 dataOut.close();
158             } catch (IOException e) {
159                 logger.debug("Error closing dataOut: {}", e.getMessage());
160             }
161             this.dataOut = null;
162         }
163         InputStream dataIn = this.dataIn;
164         if (dataIn != null) {
165             try {
166                 dataIn.close();
167             } catch (IOException e) {
168                 logger.debug("Error closing dataIn: {}", e.getMessage());
169             }
170             this.dataIn = null;
171         }
172         if (readerThread != null) {
173             readerThread.interrupt();
174             this.readerThread = null;
175             try {
176                 readerThread.join(3000);
177             } catch (InterruptedException e) {
178                 logger.warn("Error joining readerThread: {}", e.getMessage());
179             }
180         }
181     }
182
183     /**
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.
186      *
187      * @param dataBuffer the buffer into which the data is read.
188      *
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.
191      *
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
194      *             occurs.
195      */
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");
200         }
201         try {
202             return dataIn.read(dataBuffer);
203         } catch (IOException e) {
204             throw new NuvoException("readInput failed: " + e.getMessage(), e);
205         }
206     }
207
208     /**
209      * Request the Nuvo controller to execute an inquiry command
210      *
211      * @param zone the zone for which the command is to be run
212      * @param cmd the command to execute
213      *
214      * @throws NuvoException - In case of any problem
215      */
216     public void sendQuery(NuvoEnum zone, NuvoCommand cmd) throws NuvoException {
217         sendCommand(zone.getId() + cmd.getValue() + QUERY);
218     }
219
220     /**
221      * Request the Nuvo controller to execute a command for a zone that takes no arguments (ie power on, power off,
222      * etc.)
223      *
224      * @param zone the zone for which the command is to be run
225      * @param cmd the command to execute
226      *
227      * @throws NuvoException - In case of any problem
228      */
229     public void sendCommand(NuvoEnum zone, NuvoCommand cmd) throws NuvoException {
230         sendCommand(zone.getId() + cmd.getValue());
231     }
232
233     /**
234      * Request the Nuvo controller to execute a command for a zone and pass in a value
235      *
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.
239      *
240      * @throws NuvoException - In case of any problem
241      */
242     public void sendCommand(NuvoEnum zone, NuvoCommand cmd, @Nullable String value) throws NuvoException {
243         sendCommand(zone.getId() + cmd.getValue() + value);
244     }
245
246     /**
247      * Request the Nuvo controller to execute a configuration command for a zone and pass in a value
248      *
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.
252      *
253      * @throws NuvoException - In case of any problem
254      */
255     public void sendCfgCommand(NuvoEnum zone, NuvoCommand cmd, @Nullable String value) throws NuvoException {
256         sendCommand(zone.getConfigId() + cmd.getValue() + value);
257     }
258
259     /**
260      * Request the Nuvo controller to execute a system command the does not specify a zone or value
261      *
262      * @param cmd the command to execute
263      *
264      * @throws NuvoException - In case of any problem
265      */
266     public void sendCommand(NuvoCommand cmd) throws NuvoException {
267         sendCommand(cmd.getValue());
268     }
269
270     /**
271      * Request the Nuvo controller to execute a raw command string
272      *
273      * @param command the command string to run
274      *
275      * @throws NuvoException - In case of any problem
276      */
277     public void sendCommand(String command) throws NuvoException {
278         String messageStr = BEGIN_CMD + command + END_CMD;
279
280         logger.debug("sending command: {}", messageStr);
281
282         OutputStream dataOut = this.dataOut;
283         if (dataOut == null) {
284             throw new NuvoException("Send command \"" + messageStr + "\" failed: output stream is null");
285         }
286         try {
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;
293             }
294             dataOut.write(messageStr.getBytes(StandardCharsets.US_ASCII));
295             dataOut.flush();
296         } catch (IOException e) {
297             throw new NuvoException("Send command \"" + command + "\" failed: " + e.getMessage(), e);
298         }
299     }
300
301     /**
302      * Add a listener to the list of listeners to be notified with events
303      *
304      * @param listener the listener
305      */
306     public void addEventListener(NuvoMessageEventListener listener) {
307         listeners.add(listener);
308     }
309
310     /**
311      * Remove a listener from the list of listeners to be notified with events
312      *
313      * @param listener the listener
314      */
315     public void removeEventListener(NuvoMessageEventListener listener) {
316         listeners.remove(listener);
317     }
318
319     /**
320      * Analyze an incoming message and dispatch corresponding (type, zone, src, value) to the event listeners
321      *
322      * @param incomingMessage the received message
323      */
324     public void handleIncomingMessage(byte[] incomingMessage) {
325         String message = new String(incomingMessage, StandardCharsets.US_ASCII).trim();
326
327         logger.debug("handleIncomingMessage: {}", message);
328
329         if (COMMAND_ERROR.equals(message) || COMMAND_OK.equals(message)) {
330             // ignore
331             return;
332         }
333
334         if (message.contains(PING)) {
335             try {
336                 sendCommand(PING_RESPONSE);
337             } catch (NuvoException e) {
338                 logger.debug("Error sending response to PING command");
339             }
340             dispatchKeyValue(TYPE_PING, BLANK);
341             return;
342         }
343
344         if (RESTART.equals(message)) {
345             dispatchKeyValue(TYPE_RESTART, BLANK);
346             return;
347         }
348
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]);
353             return;
354         }
355
356         if (message.equals(ALLOFF)) {
357             isStandbyMode = true;
358             dispatchKeyValue(TYPE_ALLOFF, BLANK);
359             return;
360         }
361
362         if (message.contains(MUTE)) {
363             dispatchKeyValue(TYPE_ALLMUTE, message.substring(message.length() - 1));
364             return;
365         }
366
367         if (message.contains(PAGE)) {
368             dispatchKeyValue(TYPE_PAGE, message.substring(message.length() - 1));
369             return;
370         }
371
372         Matcher matcher;
373
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));
379                 return;
380             }
381
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));
386                 return;
387             }
388
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));
393                 return;
394             }
395         }
396
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));
402             return;
403         }
404
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;
411             }
412             return;
413         }
414
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
420                 return;
421             }
422
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);
428
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]);
435                     } else {
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]);
438                     }
439                 }
440                 return;
441             }
442
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));
447                 return;
448             }
449         }
450
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));
455             return;
456         }
457
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));
463             return;
464         }
465
466         logger.debug("unhandled message: {}", message);
467     }
468
469     /**
470      * Dispatch a system level event without zone or src to the event listeners
471      *
472      * @param type the type
473      * @param value the value
474      */
475     private void dispatchKeyValue(String type, String value) {
476         dispatchKeyValue(type, BLANK, BLANK, value);
477     }
478
479     /**
480      * Dispatch an event (type, zone, src, value) to the event listeners
481      *
482      * @param type the type
483      * @param zone the zone id
484      * @param src the source id
485      * @param value the value
486      */
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));
490     }
491 }