]> git.basschouten.com Git - openhab-addons.git/blob
f8da60b440519750caedff5444d76fbc748e7354
[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 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";
52
53     private static final byte[] WAKE_STR = "\r".getBytes(StandardCharsets.US_ASCII);
54
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(.*)$");
61
62     private static final Pattern ZONE_CFG_PATTERN = Pattern.compile("^#ZCFG(\\d{1,2}),(.*)$");
63
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(.*)$");
66
67     // S2ALBUMARTFRAGREQ0x620FD879,0,750
68     private static final Pattern NN_ALBUM_ART_FRAG_REQ = Pattern.compile("^#S(\\d{1})ALBUMARTFRAGREQ(.*)$");
69
70     // S6FAVORITE0x000003E8
71     private static final Pattern NN_FAVORITE_PATTERN = Pattern.compile("^#S(\\d{1})FAVORITE0x(.*)$");
72
73     private final Logger logger = LoggerFactory.getLogger(NuvoConnector.class);
74
75     protected static final String COMMAND_ERROR = "#?";
76
77     /** The output stream */
78     protected @Nullable OutputStream dataOut;
79
80     /** The input stream */
81     protected @Nullable InputStream dataIn;
82
83     /** true if the connection is established, false if not */
84     private boolean connected;
85
86     private @Nullable Thread readerThread;
87
88     private List<NuvoMessageEventListener> listeners = new ArrayList<>();
89
90     private boolean isEssentia = true;
91     private boolean isAnyOhNuvoNet = false;
92
93     /**
94      * Get whether the connection is established or not
95      *
96      * @return true if the connection is established
97      */
98     public boolean isConnected() {
99         return connected;
100     }
101
102     /**
103      * Set whether the connection is established or not
104      *
105      * @param connected true if the connection is established
106      */
107     protected void setConnected(boolean connected) {
108         this.connected = connected;
109     }
110
111     /**
112      * Tell the connector if the device is an Essentia G or not
113      *
114      * @param true if the device is an Essentia G
115      */
116     public void setEssentia(boolean isEssentia) {
117         this.isEssentia = isEssentia;
118     }
119
120     /**
121      * Tell the connector to listen for NuvoNet source messages
122      *
123      * @param true if any sources are configured as openHAB NuvoNet sources
124      */
125     public void setAnyOhNuvoNet(boolean isAnyOhNuvoNet) {
126         this.isAnyOhNuvoNet = isAnyOhNuvoNet;
127     }
128
129     /**
130      * Set the thread that handles the feedback messages
131      *
132      * @param readerThread the thread
133      */
134     protected void setReaderThread(Thread readerThread) {
135         this.readerThread = readerThread;
136     }
137
138     /**
139      * Open the connection with the Nuvo device
140      *
141      * @throws NuvoException - In case of any problem
142      */
143     public abstract void open() throws NuvoException;
144
145     /**
146      * Close the connection with the Nuvo device
147      */
148     public abstract void close();
149
150     /**
151      * Stop the thread that handles the feedback messages and close the opened input and output streams
152      */
153     protected void cleanup() {
154         Thread readerThread = this.readerThread;
155         OutputStream dataOut = this.dataOut;
156         if (dataOut != null) {
157             try {
158                 dataOut.close();
159             } catch (IOException e) {
160                 logger.debug("Error closing dataOut: {}", e.getMessage());
161             }
162             this.dataOut = null;
163         }
164         InputStream dataIn = this.dataIn;
165         if (dataIn != null) {
166             try {
167                 dataIn.close();
168             } catch (IOException e) {
169                 logger.debug("Error closing dataIn: {}", e.getMessage());
170             }
171             this.dataIn = null;
172         }
173         if (readerThread != null) {
174             readerThread.interrupt();
175             this.readerThread = null;
176             try {
177                 readerThread.join(3000);
178             } catch (InterruptedException e) {
179                 logger.warn("Error joining readerThread: {}", e.getMessage());
180             }
181         }
182     }
183
184     /**
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.
187      *
188      * @param dataBuffer the buffer into which the data is read.
189      *
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.
192      *
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
195      *             occurs.
196      */
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");
201         }
202         try {
203             return dataIn.read(dataBuffer);
204         } catch (IOException e) {
205             throw new NuvoException("readInput failed: " + e.getMessage(), e);
206         }
207     }
208
209     /**
210      * Request the Nuvo controller to execute an inquiry command
211      *
212      * @param zone the zone for which the command is to be run
213      * @param cmd the command to execute
214      *
215      * @throws NuvoException - In case of any problem
216      */
217     public void sendQuery(NuvoEnum zone, NuvoCommand cmd) throws NuvoException {
218         sendCommand(zone.getId() + cmd.getValue() + QUERY);
219     }
220
221     /**
222      * Request the Nuvo controller to execute a command for a zone that takes no arguments (ie power on, power off,
223      * etc.)
224      *
225      * @param zone the zone for which the command is to be run
226      * @param cmd the command to execute
227      *
228      * @throws NuvoException - In case of any problem
229      */
230     public void sendCommand(NuvoEnum zone, NuvoCommand cmd) throws NuvoException {
231         sendCommand(zone.getId() + cmd.getValue());
232     }
233
234     /**
235      * Request the Nuvo controller to execute a command for a zone and pass in a value
236      *
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.
240      *
241      * @throws NuvoException - In case of any problem
242      */
243     public void sendCommand(NuvoEnum zone, NuvoCommand cmd, @Nullable String value) throws NuvoException {
244         sendCommand(zone.getId() + cmd.getValue() + value);
245     }
246
247     /**
248      * Request the Nuvo controller to execute a configuration command for a zone and pass in a value
249      *
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.
253      *
254      * @throws NuvoException - In case of any problem
255      */
256     public void sendCfgCommand(NuvoEnum zone, NuvoCommand cmd, @Nullable String value) throws NuvoException {
257         sendCommand(zone.getConfigId() + cmd.getValue() + value);
258     }
259
260     /**
261      * Request the Nuvo controller to execute a system command the does not specify a zone or value
262      *
263      * @param cmd the command to execute
264      *
265      * @throws NuvoException - In case of any problem
266      */
267     public void sendCommand(NuvoCommand cmd) throws NuvoException {
268         sendCommand(cmd.getValue());
269     }
270
271     /**
272      * Request the Nuvo controller to execute a raw command string
273      *
274      * @param command the command string to run
275      *
276      * @throws NuvoException - In case of any problem
277      */
278     public void sendCommand(@Nullable String command) throws NuvoException {
279         String messageStr = BEGIN_CMD + command + END_CMD;
280
281         logger.debug("sending command: {}", messageStr);
282
283         OutputStream dataOut = this.dataOut;
284         if (dataOut == null) {
285             throw new NuvoException("Send command \"" + messageStr + "\" failed: output stream is null");
286         }
287         try {
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);
292                 dataOut.flush();
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(ALL_OFF)) {
357             dispatchKeyValue(TYPE_ALLOFF, BLANK);
358             return;
359         }
360
361         if (message.contains(MUTE)) {
362             dispatchKeyValue(TYPE_ALLMUTE, message.substring(message.length() - 1));
363             return;
364         }
365
366         if (message.contains(PAGE)) {
367             dispatchKeyValue(TYPE_PAGE, message.substring(message.length() - 1));
368             return;
369         }
370
371         Matcher matcher;
372
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));
378                 return;
379             }
380
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));
385                 return;
386             }
387
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));
392                 return;
393             }
394         }
395
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));
401             return;
402         }
403
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));
408             return;
409         }
410
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
416                 return;
417             }
418
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);
424
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]);
431                     } else {
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]);
434                     }
435                 }
436                 return;
437             }
438
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));
443                 return;
444             }
445         }
446
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));
451             return;
452         }
453
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));
459             return;
460         }
461
462         logger.debug("unhandled message: {}", message);
463     }
464
465     /**
466      * Dispatch a system level event without zone or src to the event listeners
467      *
468      * @param type the type
469      * @param value the value
470      */
471     private void dispatchKeyValue(String type, String value) {
472         dispatchKeyValue(type, BLANK, BLANK, value);
473     }
474
475     /**
476      * Dispatch an event (type, zone, src, value) to the event listeners
477      *
478      * @param type the type
479      * @param zone the zone id
480      * @param src the source id
481      * @param value the value
482      */
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));
486     }
487 }