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