]> git.basschouten.com Git - openhab-addons.git/blob
3d14c817a2c2f4bcf95eff2b166f8322a36d7d74
[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.russound.internal.rio;
14
15 import java.util.ArrayList;
16 import java.util.List;
17 import java.util.Objects;
18 import java.util.concurrent.CopyOnWriteArrayList;
19 import java.util.concurrent.locks.Lock;
20 import java.util.concurrent.locks.ReentrantLock;
21 import java.util.regex.Matcher;
22 import java.util.regex.Pattern;
23
24 import org.eclipse.jdt.annotation.Nullable;
25 import org.openhab.binding.russound.internal.net.SocketSession;
26 import org.openhab.binding.russound.internal.net.SocketSessionListener;
27 import org.openhab.binding.russound.internal.rio.models.GsonUtilities;
28 import org.openhab.binding.russound.internal.rio.models.RioPreset;
29 import org.slf4j.Logger;
30 import org.slf4j.LoggerFactory;
31
32 import com.google.gson.Gson;
33 import com.google.gson.JsonSyntaxException;
34
35 /**
36  * This {@link AbstractRioProtocol} implementation provides the implementation for managing Russound bank presets.
37  * Since refreshing all 36 presets requires 72 calls to russound (for name/valid), we limit how often we can
38  * refresh to {@link #UPDATE_TIME_SPAN}. Presets are tracked by source ID and will only be valid if that source type is
39  * a tuner.
40  *
41  * @author Tim Roberts - Initial contribution
42  */
43 public class RioPresetsProtocol extends AbstractRioProtocol {
44
45     // logger
46     private final Logger logger = LoggerFactory.getLogger(RioPresetsProtocol.class);
47
48     // helper names
49     private static final String PRESET_NAME = "name";
50     private static final String PRESET_VALID = "valid";
51
52     /**
53      * The pattern representing preset notifications
54      */
55     private static final Pattern RSP_PRESETNOTIFICATION = Pattern
56             .compile("(?i)^[SN] S\\[(\\d+)\\].B\\[(\\d+)\\].P\\[(\\d+)\\].(\\w+)=\"(.*)\"$");
57
58     /**
59      * The pattern representing a source type notification
60      */
61     private static final Pattern RSP_SRCTYPENOTIFICATION = Pattern.compile("^[SN] S\\[(\\d+)\\]\\.type=\"(.*)\"$");
62
63     /**
64      * All 36 presets represented by two dimensions - 8 source by 36 presets
65      */
66     private final RioPreset[][] presets = new RioPreset[8][36];
67
68     /**
69      * Represents whether the source is a tuner or not
70      */
71     private final boolean[] isTuner = new boolean[8];
72
73     /**
74      * The {@link Gson} used for JSON operations
75      */
76     private final Gson gson;
77
78     /**
79      * The {@link ReentrantLock} used to control access to {@link #lastUpdateTime}
80      */
81     private final Lock lastUpdateLock = new ReentrantLock();
82
83     /**
84      * The last time the specified source presets were updated via {@link #refreshPresets(Integer)}
85      */
86     private final long[] lastUpdateTime = new long[8];
87
88     /**
89      * The minimum timespan between updates of source presets via {@link #refreshPresets(Integer)}
90      */
91     private static final long UPDATE_TIME_SPAN = 60000;
92
93     /**
94      * The pattern to determine if the source type is a tuner
95      */
96     private static final Pattern IS_TUNER = Pattern.compile("(?i)^.*AM.*FM.*TUNER.*$");
97
98     /**
99      * The list of listeners that will be called when system favorites have changed
100      */
101     private final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
102
103     /**
104      * Constructs the preset protocol from the given session and callback. Note: the passed callback is not
105      * currently used
106      *
107      * @param session a non null {@link SocketSession} to use
108      * @param callback a non-null {@link RioHandlerCallback} to use
109      */
110     public RioPresetsProtocol(SocketSession session, RioHandlerCallback callback) {
111         super(session, callback);
112
113         gson = GsonUtilities.createGson();
114         for (int s = 1; s <= 8; s++) {
115             sendCommand("GET S[" + s + "].type");
116
117             for (int x = 1; x <= 36; x++) {
118                 presets[s - 1][x - 1] = new RioPreset(x);
119             }
120         }
121     }
122
123     /**
124      * Adds the specified listener to changes to presets
125      *
126      * @param listener a non-null listener to add
127      * @throws IllegalArgumentException if listener is null
128      */
129     public void addListener(Listener listener) {
130         listeners.add(listener);
131     }
132
133     /**
134      * Removes the specified listener from change notifications
135      *
136      * @param listener a possibly null listener to remove (null is ignored)
137      * @return true if removed, false otherwise
138      */
139     public boolean removeListener(Listener listener) {
140         return listeners.remove(listener);
141     }
142
143     /**
144      * Fires the presetsUpdate method on all listeners with the results of {@link #getJson()} for the given source
145      *
146      * @param sourceId a valid source identifier between 1 and 8
147      * @throws IllegalArgumentException if sourceId is < 1 or > 8
148      */
149     private void fireUpdate(int sourceId) {
150         if (sourceId < 1 || sourceId > 8) {
151             throw new IllegalArgumentException("sourceId must be between 1 and 8");
152         }
153         final String json = getJson(sourceId);
154         for (Listener l : listeners) {
155             l.presetsUpdated(sourceId, json);
156         }
157     }
158
159     /**
160      * Helper method to request the specified presets id information (name/valid) for a given source. Please note that
161      * this does NOT change the {@link #lastUpdateTime}
162      *
163      * @param sourceId a source identifier between 1 and 8
164      * @param favIds a non-null, possibly empty list of system favorite ids to request (any id < 1 or > 32 will be
165      *            ignored)
166      * @throws IllegalArgumentException if favIds is null
167      * @throws IllegalArgumentException if sourceId is < 1 or > 8
168      */
169     private void requestPresets(int sourceId, List<RioPreset> presetIds) {
170         if (sourceId < 1 || sourceId > 8) {
171             throw new IllegalArgumentException("sourceId must be between 1 and 8");
172         }
173         if (presetIds == null) {
174             throw new IllegalArgumentException("presetIds must not be null");
175         }
176         for (RioPreset preset : presetIds) {
177             sendCommand("GET S[" + sourceId + "].B[" + preset.getBank() + "].P[" + preset.getBankPreset() + "].valid");
178             sendCommand("GET S[" + sourceId + "].B[" + preset.getBank() + "].P[" + preset.getBankPreset() + "].name");
179         }
180     }
181
182     /**
183      * Refreshes ALL presets for all sources. Simply calls {@link #refreshPresets(Integer)} with each source identifier
184      */
185     public void refreshPresets() {
186         for (int sourceId = 1; sourceId <= 8; sourceId++) {
187             refreshPresets(sourceId);
188         }
189     }
190
191     /**
192      * Refreshes ALL presets for the given sourceId if they have not been refreshed within the last
193      * {@link #UPDATE_TIME_SPAN}. This method WILL change the {@link #lastUpdateTime}. No calls will be made if the
194      * source type is not a tuner (however the {@link #lastUpdateTime} will be reset).
195      *
196      * @param sourceId a source identifier between 1 and 8
197      * @throws IllegalArgumentException if sourceId is {@code < 1} or {@code > 8}
198      */
199     public void refreshPresets(Integer sourceId) {
200         if (sourceId < 1 || sourceId > 8) {
201             throw new IllegalArgumentException("sourceId must be between 1 and 8");
202         }
203         lastUpdateLock.lock();
204         try {
205             final long now = System.currentTimeMillis();
206             if (now > lastUpdateTime[sourceId - 1] + UPDATE_TIME_SPAN) {
207                 lastUpdateTime[sourceId - 1] = now;
208
209                 if (isTuner[sourceId - 1]) {
210                     for (int x = 1; x <= 36; x++) {
211                         final RioPreset preset = presets[sourceId - 1][x - 1];
212                         sendCommand("GET S[" + sourceId + "].B[" + preset.getBank() + "].P[" + preset.getBankPreset()
213                                 + "].valid");
214                         sendCommand("GET S[" + sourceId + "].B[" + preset.getBank() + "].P[" + preset.getBankPreset()
215                                 + "].name");
216                     }
217                 }
218             }
219         } finally {
220             lastUpdateLock.unlock();
221         }
222     }
223
224     /**
225      * Returns the JSON representation of the presets for the sourceId and their state. If the sourceId does not
226      * represent a tuner, then an empty array JSON representation ("[]") will be returned.
227      *
228      * @return A non-null, non-empty JSON representation of {@link #_systemFavorites}
229      */
230     public String getJson(int source) {
231         if (!isTuner[source - 1]) {
232             return "[]";
233         }
234
235         final List<RioPreset> validPresets = new ArrayList<>();
236         for (final RioPreset preset : presets[source - 1]) {
237             if (preset.isValid()) {
238                 validPresets.add(preset);
239             }
240         }
241
242         return gson.toJson(validPresets);
243     }
244
245     /**
246      * Sets a zone preset. NOTE: at this time, only a single preset can be represented in the presetJson. Having more
247      * than one preset saved to the same underlying channel causes the russound system to become a little unstable. This
248      * method will save the preset if the status is changed from not valid to valid or if the name is simply changing on
249      * a currently valid preset. The preset will be deleted if status is changed from valid to not valid. When saving a
250      * preset and the name is not specified, the russound system will automatically assign a name equal to the channel
251      * being played.
252      *
253      * @param controller a controller between 1 and 6
254      * @param zone a zone between 1 and 8
255      * @param source a source between 1 and 8
256      * @param presetJson the possibly empty, possibly null JSON representation of the preset
257      * @throws IllegalArgumentException if controller is {@literal <} 1 or > 6
258      * @throws IllegalArgumentException if zone is {@literal <} 1 or > 8
259      * @throws IllegalArgumentException if source is {@literal <} 1 or > 8
260      * @throws IllegalArgumentException if presetJson contains more than one preset
261      */
262     public void setZonePresets(int controller, int zone, int source, @Nullable String presetJson) {
263         if (controller < 1 || controller > 6) {
264             throw new IllegalArgumentException("Controller must be between 1 and 6");
265         }
266
267         if (zone < 1 || zone > 8) {
268             throw new IllegalArgumentException("Zone must be between 1 and 8");
269         }
270
271         if (source < 1 || source > 8) {
272             throw new IllegalArgumentException("Source must be between 1 and 8");
273         }
274
275         if (presetJson == null || presetJson.isEmpty()) {
276             return;
277         }
278
279         final List<RioPreset> updatePresetIds = new ArrayList<>();
280         try {
281             final RioPreset[] newPresets = gson.fromJson(presetJson, RioPreset[].class);
282
283             // Keeps from screwing up the system if you set a bunch of presets to the same playing
284             if (newPresets.length > 1) {
285                 throw new IllegalArgumentException("Can only save a single preset at a time");
286             }
287
288             for (int x = newPresets.length - 1; x >= 0; x--) {
289                 final RioPreset preset = newPresets[x];
290                 if (preset == null) {
291                     continue;// caused by {id,valid,name},,{id,valid,name}
292                 }
293                 final int presetId = preset.getId();
294                 if (presetId < 1 || presetId > 36) {
295                     logger.debug("Invalid preset id (not between 1 and 36) - ignoring: {}:{}", presetId, presetJson);
296                 } else {
297                     final RioPreset myPreset = presets[source][presetId];
298                     final boolean presetValid = preset.isValid();
299                     final String presetName = preset.getName();
300
301                     // re-retrieve to see if the save/delete worked (saving on a zone that's off - valid won't be set to
302                     // true)
303                     if (!Objects.equals(myPreset.getName(), presetName) || myPreset.isValid() != presetValid) {
304                         myPreset.setName(presetName);
305                         myPreset.setValid(presetValid);
306                         if (presetValid) {
307                             if (presetName == null || presetName.isEmpty()) {
308                                 sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!savePreset " + presetId);
309                             } else {
310                                 sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!savePreset \"" + presetName
311                                         + "\" " + presetId);
312                             }
313
314                             updatePresetIds.add(preset);
315                         } else {
316                             sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!deletePreset " + presetId);
317                         }
318                     }
319                 }
320             }
321         } catch (JsonSyntaxException e) {
322             logger.debug("Invalid JSON: {}", e.getMessage(), e);
323         }
324
325         // Invalid the presets we updated
326         requestPresets(source, updatePresetIds);
327
328         // Refresh our channel since 'presetJson' occupies it right now
329         fireUpdate(source);
330     }
331
332     /**
333      * Handles any system notifications returned by the russound system
334      *
335      * @param m a non-null matcher
336      * @param resp a possibly null, possibly empty response
337      */
338     void handlePresetNotification(Matcher m, String resp) {
339         if (m == null) {
340             throw new IllegalArgumentException("m (matcher) cannot be null");
341         }
342
343         if (m.groupCount() == 5) {
344             try {
345                 final int source = Integer.parseInt(m.group(1));
346                 if (source >= 1 && source <= 8) {
347                     final int bank = Integer.parseInt(m.group(2));
348                     if (bank >= 1 && bank <= 6) {
349                         final int preset = Integer.parseInt(m.group(3));
350                         if (preset >= 1 && preset <= 6) {
351                             final String key = m.group(4).toLowerCase();
352                             final String value = m.group(5);
353
354                             final RioPreset rioPreset = presets[source - 1][(bank - 1) * 6 + preset - 1];
355
356                             switch (key) {
357                                 case PRESET_NAME:
358                                     rioPreset.setName(value);
359                                     fireUpdate(source);
360                                     break;
361
362                                 case PRESET_VALID:
363                                     rioPreset.setValid(!"false".equalsIgnoreCase(value));
364                                     fireUpdate(source);
365                                     break;
366
367                                 default:
368                                     logger.warn("Unknown preset notification: '{}'", resp);
369                                     break;
370                             }
371                         } else {
372                             logger.debug("Preset ID must be between 1 and 6: {}", resp);
373                         }
374                     } else {
375                         logger.debug("Bank ID must be between 1 and 6: {}", resp);
376
377                     }
378                 } else {
379                     logger.debug("Source ID must be between 1 and 8: {}", resp);
380                 }
381             } catch (NumberFormatException e) {
382                 logger.warn("Invalid Preset Notification (source/bank/preset not a parsable integer): '{}')", resp);
383             }
384         } else {
385             logger.warn("Invalid Preset Notification: '{}')", resp);
386         }
387     }
388
389     /**
390      * Handles any preset notifications returned by the russound system
391      *
392      * @param m a non-null matcher
393      * @param resp a possibly null, possibly empty response
394      */
395     private void handlerSourceTypeNotification(Matcher m, String resp) {
396         if (m == null) {
397             throw new IllegalArgumentException("m (matcher) cannot be null");
398         }
399
400         if (m.groupCount() == 2) {
401             try {
402                 final int sourceId = Integer.parseInt(m.group(1));
403                 if (sourceId >= 1 && sourceId <= 8) {
404                     final String sourceType = m.group(2);
405
406                     final Matcher matcher = IS_TUNER.matcher(sourceType);
407                     final boolean srcIsTuner = matcher.matches();
408
409                     if (srcIsTuner != isTuner[sourceId - 1]) {
410                         isTuner[sourceId - 1] = srcIsTuner;
411
412                         if (srcIsTuner) {
413                             // force a refresh on the source
414                             lastUpdateTime[sourceId - 1] = 0;
415                             refreshPresets(sourceId);
416                         } else {
417                             for (int p = 0; p < 36; p++) {
418                                 presets[sourceId - 1][p].setValid(false);
419                                 presets[sourceId - 1][p].setName(null);
420                             }
421                         }
422                         fireUpdate(sourceId);
423                     }
424                 } else {
425                     logger.debug("Source is not between 1 and 8, Response: {}", resp);
426                 }
427             } catch (NumberFormatException e) {
428                 logger.warn("Invalid Preset Notification (source/bank/preset not a parsable integer): '{}')", resp);
429             }
430         } else {
431             logger.warn("Invalid Preset Notification: '{}')", resp);
432         }
433     }
434
435     /**
436      * Implements {@link SocketSessionListener#responseReceived(String)} to try to process the response from the
437      * russound system. This response may be for other protocol handler - so ignore if we don't recognize the response.
438      *
439      * @param response a possibly null, possibly empty response
440      */
441     @Override
442     public void responseReceived(@Nullable String response) {
443         if (response == null || response.isEmpty()) {
444             return;
445         }
446
447         Matcher m = RSP_PRESETNOTIFICATION.matcher(response);
448         if (m.matches()) {
449             handlePresetNotification(m, response);
450         }
451
452         m = RSP_SRCTYPENOTIFICATION.matcher(response);
453         if (m.matches()) {
454             handlerSourceTypeNotification(m, response);
455         }
456     }
457
458     /**
459      * Defines the listener implementation to list for preset updates
460      *
461      * @author Tim Roberts
462      *
463      */
464     public interface Listener {
465         /**
466          * Called when presets have changed for a specific sourceId. The jsonString will contain the current
467          * representation of all valid presets for the source.
468          *
469          * @param sourceId a source identifier between 1 and 8
470          * @param jsonString a non-null, non-empty json representation of {@link RioPreset}
471          */
472         void presetsUpdated(int sourceId, String jsonString);
473     }
474 }