2 * Copyright (c) 2010-2021 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.russound.internal.rio;
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;
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;
31 import com.google.gson.Gson;
32 import com.google.gson.JsonSyntaxException;
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
40 * @author Tim Roberts - Initial contribution
42 public class RioPresetsProtocol extends AbstractRioProtocol {
45 private final Logger logger = LoggerFactory.getLogger(RioPresetsProtocol.class);
48 private static final String PRESET_NAME = "name";
49 private static final String PRESET_VALID = "valid";
52 * The pattern representing preset notifications
54 private static final Pattern RSP_PRESETNOTIFICATION = Pattern
55 .compile("(?i)^[SN] S\\[(\\d+)\\].B\\[(\\d+)\\].P\\[(\\d+)\\].(\\w+)=\"(.*)\"$");
58 * The pattern representing a source type notification
60 private static final Pattern RSP_SRCTYPENOTIFICATION = Pattern.compile("^[SN] S\\[(\\d+)\\]\\.type=\"(.*)\"$");
63 * All 36 presets represented by two dimensions - 8 source by 36 presets
65 private final RioPreset[][] presets = new RioPreset[8][36];
68 * Represents whether the source is a tuner or not
70 private final boolean[] isTuner = new boolean[8];
73 * The {@link Gson} used for JSON operations
75 private final Gson gson;
78 * The {@link ReentrantLock} used to control access to {@link #lastUpdateTime}
80 private final Lock lastUpdateLock = new ReentrantLock();
83 * The last time the specified source presets were updated via {@link #refreshPresets(Integer)}
85 private final long[] lastUpdateTime = new long[8];
88 * The minimum timespan between updates of source presets via {@link #refreshPresets(Integer)}
90 private static final long UPDATE_TIME_SPAN = 60000;
93 * The pattern to determine if the source type is a tuner
95 private static final Pattern IS_TUNER = Pattern.compile("(?i)^.*AM.*FM.*TUNER.*$");
98 * The list of listeners that will be called when system favorites have changed
100 private final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
103 * Constructs the preset protocol from the given session and callback. Note: the passed callback is not
106 * @param session a non null {@link SocketSession} to use
107 * @param callback a non-null {@link RioHandlerCallback} to use
109 public RioPresetsProtocol(SocketSession session, RioHandlerCallback callback) {
110 super(session, callback);
112 gson = GsonUtilities.createGson();
113 for (int s = 1; s <= 8; s++) {
114 sendCommand("GET S[" + s + "].type");
116 for (int x = 1; x <= 36; x++) {
117 presets[s - 1][x - 1] = new RioPreset(x);
123 * Adds the specified listener to changes to presets
125 * @param listener a non-null listener to add
126 * @throws IllegalArgumentException if listener is null
128 public void addListener(Listener listener) {
129 listeners.add(listener);
133 * Removes the specified listener from change notifications
135 * @param listener a possibly null listener to remove (null is ignored)
136 * @return true if removed, false otherwise
138 public boolean removeListener(Listener listener) {
139 return listeners.remove(listener);
143 * Fires the presetsUpdate method on all listeners with the results of {@link #getJson()} for the given source
145 * @param sourceId a valid source identifier between 1 and 8
146 * @throws IllegalArgumentException if sourceId is < 1 or > 8
148 private void fireUpdate(int sourceId) {
149 if (sourceId < 1 || sourceId > 8) {
150 throw new IllegalArgumentException("sourceId must be between 1 and 8");
152 final String json = getJson(sourceId);
153 for (Listener l : listeners) {
154 l.presetsUpdated(sourceId, json);
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}
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
165 * @throws IllegalArgumentException if favIds is null
166 * @throws IllegalArgumentException if sourceId is < 1 or > 8
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");
172 if (presetIds == null) {
173 throw new IllegalArgumentException("presetIds must not be null");
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");
182 * Refreshes ALL presets for all sources. Simply calls {@link #refreshPresets(Integer)} with each source identifier
184 public void refreshPresets() {
185 for (int sourceId = 1; sourceId <= 8; sourceId++) {
186 refreshPresets(sourceId);
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).
195 * @param sourceId a source identifier between 1 and 8
196 * @throws IllegalArgumentException if sourceId is < 1 or > 8
198 public void refreshPresets(Integer sourceId) {
199 if (sourceId < 1 || sourceId > 8) {
200 throw new IllegalArgumentException("sourceId must be between 1 and 8");
202 lastUpdateLock.lock();
204 final long now = System.currentTimeMillis();
205 if (now > lastUpdateTime[sourceId - 1] + UPDATE_TIME_SPAN) {
206 lastUpdateTime[sourceId - 1] = now;
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()
213 sendCommand("GET S[" + sourceId + "].B[" + preset.getBank() + "].P[" + preset.getBankPreset()
219 lastUpdateLock.unlock();
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.
227 * @return A non-null, non-empty JSON representation of {@link #_systemFavorites}
229 public String getJson(int source) {
230 if (!isTuner[source - 1]) {
234 final List<RioPreset> validPresets = new ArrayList<>();
235 for (final RioPreset preset : presets[source - 1]) {
236 if (preset.isValid()) {
237 validPresets.add(preset);
241 return gson.toJson(validPresets);
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
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
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");
266 if (zone < 1 || zone > 8) {
267 throw new IllegalArgumentException("Zone must be between 1 and 8");
270 if (source < 1 || source > 8) {
271 throw new IllegalArgumentException("Source must be between 1 and 8");
274 if (StringUtils.isEmpty(presetJson)) {
278 final List<RioPreset> updatePresetIds = new ArrayList<>();
280 final RioPreset[] newPresets = gson.fromJson(presetJson, RioPreset[].class);
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");
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}
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);
296 final RioPreset myPreset = presets[source][presetId];
297 final boolean presetValid = preset.isValid();
298 final String presetName = preset.getName();
300 // re-retrieve to see if the save/delete worked (saving on a zone that's off - valid won't be set to
302 if (!StringUtils.equals(myPreset.getName(), presetName) || myPreset.isValid() != presetValid) {
303 myPreset.setName(presetName);
304 myPreset.setValid(presetValid);
306 if (StringUtils.isEmpty(presetName)) {
307 sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!savePreset " + presetId);
309 sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!savePreset \"" + presetName
313 updatePresetIds.add(preset);
315 sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!deletePreset " + presetId);
320 } catch (JsonSyntaxException e) {
321 logger.debug("Invalid JSON: {}", e.getMessage(), e);
324 // Invalid the presets we updated
325 requestPresets(source, updatePresetIds);
327 // Refresh our channel since 'presetJson' occupies it right now
332 * Handles any system notifications returned by the russound system
334 * @param m a non-null matcher
335 * @param resp a possibly null, possibly empty response
337 void handlePresetNotification(Matcher m, String resp) {
339 throw new IllegalArgumentException("m (matcher) cannot be null");
342 if (m.groupCount() == 5) {
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);
353 final RioPreset rioPreset = presets[source - 1][(bank - 1) * 6 + preset - 1];
357 rioPreset.setName(value);
362 rioPreset.setValid(!"false".equalsIgnoreCase(value));
367 logger.warn("Unknown preset notification: '{}'", resp);
371 logger.debug("Preset ID must be between 1 and 6: {}", resp);
374 logger.debug("Bank ID must be between 1 and 6: {}", resp);
378 logger.debug("Source ID must be between 1 and 8: {}", resp);
380 } catch (NumberFormatException e) {
381 logger.warn("Invalid Preset Notification (source/bank/preset not a parsable integer): '{}')", resp);
384 logger.warn("Invalid Preset Notification: '{}')", resp);
389 * Handles any preset notifications returned by the russound system
391 * @param m a non-null matcher
392 * @param resp a possibly null, possibly empty response
394 private void handlerSourceTypeNotification(Matcher m, String resp) {
396 throw new IllegalArgumentException("m (matcher) cannot be null");
399 if (m.groupCount() == 2) {
401 final int sourceId = Integer.parseInt(m.group(1));
402 if (sourceId >= 1 && sourceId <= 8) {
403 final String sourceType = m.group(2);
405 final Matcher matcher = IS_TUNER.matcher(sourceType);
406 final boolean srcIsTuner = matcher.matches();
408 if (srcIsTuner != isTuner[sourceId - 1]) {
409 isTuner[sourceId - 1] = srcIsTuner;
412 // force a refresh on the source
413 lastUpdateTime[sourceId - 1] = 0;
414 refreshPresets(sourceId);
416 for (int p = 0; p < 36; p++) {
417 presets[sourceId - 1][p].setValid(false);
418 presets[sourceId - 1][p].setName(null);
421 fireUpdate(sourceId);
424 logger.debug("Source is not between 1 and 8, Response: {}", resp);
426 } catch (NumberFormatException e) {
427 logger.warn("Invalid Preset Notification (source/bank/preset not a parsable integer): '{}')", resp);
430 logger.warn("Invalid Preset Notification: '{}')", resp);
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.
438 * @param a possibly null, possibly empty response
441 public void responseReceived(String response) {
442 if (StringUtils.isEmpty(response)) {
446 Matcher m = RSP_PRESETNOTIFICATION.matcher(response);
448 handlePresetNotification(m, response);
451 m = RSP_SRCTYPENOTIFICATION.matcher(response);
453 handlerSourceTypeNotification(m, response);
458 * Defines the listener implementation to list for preset updates
460 * @author Tim Roberts
463 public interface Listener {
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.
468 * @param sourceId a source identifier between 1 and 8
469 * @param jsonString a non-null, non-empty json representation of {@link RioPreset}
471 void presetsUpdated(int sourceId, String jsonString);