2 * Copyright (c) 2010-2023 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.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;
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;
32 import com.google.gson.Gson;
33 import com.google.gson.JsonSyntaxException;
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
41 * @author Tim Roberts - Initial contribution
43 public class RioPresetsProtocol extends AbstractRioProtocol {
46 private final Logger logger = LoggerFactory.getLogger(RioPresetsProtocol.class);
49 private static final String PRESET_NAME = "name";
50 private static final String PRESET_VALID = "valid";
53 * The pattern representing preset notifications
55 private static final Pattern RSP_PRESETNOTIFICATION = Pattern
56 .compile("(?i)^[SN] S\\[(\\d+)\\].B\\[(\\d+)\\].P\\[(\\d+)\\].(\\w+)=\"(.*)\"$");
59 * The pattern representing a source type notification
61 private static final Pattern RSP_SRCTYPENOTIFICATION = Pattern.compile("^[SN] S\\[(\\d+)\\]\\.type=\"(.*)\"$");
64 * All 36 presets represented by two dimensions - 8 source by 36 presets
66 private final RioPreset[][] presets = new RioPreset[8][36];
69 * Represents whether the source is a tuner or not
71 private final boolean[] isTuner = new boolean[8];
74 * The {@link Gson} used for JSON operations
76 private final Gson gson;
79 * The {@link ReentrantLock} used to control access to {@link #lastUpdateTime}
81 private final Lock lastUpdateLock = new ReentrantLock();
84 * The last time the specified source presets were updated via {@link #refreshPresets(Integer)}
86 private final long[] lastUpdateTime = new long[8];
89 * The minimum timespan between updates of source presets via {@link #refreshPresets(Integer)}
91 private static final long UPDATE_TIME_SPAN = 60000;
94 * The pattern to determine if the source type is a tuner
96 private static final Pattern IS_TUNER = Pattern.compile("(?i)^.*AM.*FM.*TUNER.*$");
99 * The list of listeners that will be called when system favorites have changed
101 private final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
104 * Constructs the preset protocol from the given session and callback. Note: the passed callback is not
107 * @param session a non null {@link SocketSession} to use
108 * @param callback a non-null {@link RioHandlerCallback} to use
110 public RioPresetsProtocol(SocketSession session, RioHandlerCallback callback) {
111 super(session, callback);
113 gson = GsonUtilities.createGson();
114 for (int s = 1; s <= 8; s++) {
115 sendCommand("GET S[" + s + "].type");
117 for (int x = 1; x <= 36; x++) {
118 presets[s - 1][x - 1] = new RioPreset(x);
124 * Adds the specified listener to changes to presets
126 * @param listener a non-null listener to add
127 * @throws IllegalArgumentException if listener is null
129 public void addListener(Listener listener) {
130 listeners.add(listener);
134 * Removes the specified listener from change notifications
136 * @param listener a possibly null listener to remove (null is ignored)
137 * @return true if removed, false otherwise
139 public boolean removeListener(Listener listener) {
140 return listeners.remove(listener);
144 * Fires the presetsUpdate method on all listeners with the results of {@link #getJson()} for the given source
146 * @param sourceId a valid source identifier between 1 and 8
147 * @throws IllegalArgumentException if sourceId is < 1 or > 8
149 private void fireUpdate(int sourceId) {
150 if (sourceId < 1 || sourceId > 8) {
151 throw new IllegalArgumentException("sourceId must be between 1 and 8");
153 final String json = getJson(sourceId);
154 for (Listener l : listeners) {
155 l.presetsUpdated(sourceId, json);
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}
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
166 * @throws IllegalArgumentException if favIds is null
167 * @throws IllegalArgumentException if sourceId is < 1 or > 8
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");
173 if (presetIds == null) {
174 throw new IllegalArgumentException("presetIds must not be null");
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");
183 * Refreshes ALL presets for all sources. Simply calls {@link #refreshPresets(Integer)} with each source identifier
185 public void refreshPresets() {
186 for (int sourceId = 1; sourceId <= 8; sourceId++) {
187 refreshPresets(sourceId);
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).
196 * @param sourceId a source identifier between 1 and 8
197 * @throws IllegalArgumentException if sourceId is < 1 or > 8
199 public void refreshPresets(Integer sourceId) {
200 if (sourceId < 1 || sourceId > 8) {
201 throw new IllegalArgumentException("sourceId must be between 1 and 8");
203 lastUpdateLock.lock();
205 final long now = System.currentTimeMillis();
206 if (now > lastUpdateTime[sourceId - 1] + UPDATE_TIME_SPAN) {
207 lastUpdateTime[sourceId - 1] = now;
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()
214 sendCommand("GET S[" + sourceId + "].B[" + preset.getBank() + "].P[" + preset.getBankPreset()
220 lastUpdateLock.unlock();
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.
228 * @return A non-null, non-empty JSON representation of {@link #_systemFavorites}
230 public String getJson(int source) {
231 if (!isTuner[source - 1]) {
235 final List<RioPreset> validPresets = new ArrayList<>();
236 for (final RioPreset preset : presets[source - 1]) {
237 if (preset.isValid()) {
238 validPresets.add(preset);
242 return gson.toJson(validPresets);
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
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 < 1 or > 6
258 * @throws IllegalArgumentException if zone is < 1 or > 8
259 * @throws IllegalArgumentException if source is < 1 or > 8
260 * @throws IllegalArgumentException if presetJson contains more than one preset
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");
267 if (zone < 1 || zone > 8) {
268 throw new IllegalArgumentException("Zone must be between 1 and 8");
271 if (source < 1 || source > 8) {
272 throw new IllegalArgumentException("Source must be between 1 and 8");
275 if (presetJson == null || presetJson.isEmpty()) {
279 final List<RioPreset> updatePresetIds = new ArrayList<>();
281 final RioPreset[] newPresets = gson.fromJson(presetJson, RioPreset[].class);
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");
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}
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);
297 final RioPreset myPreset = presets[source][presetId];
298 final boolean presetValid = preset.isValid();
299 final String presetName = preset.getName();
301 // re-retrieve to see if the save/delete worked (saving on a zone that's off - valid won't be set to
303 if (!Objects.equals(myPreset.getName(), presetName) || myPreset.isValid() != presetValid) {
304 myPreset.setName(presetName);
305 myPreset.setValid(presetValid);
307 if (presetName == null || presetName.isEmpty()) {
308 sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!savePreset " + presetId);
310 sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!savePreset \"" + presetName
314 updatePresetIds.add(preset);
316 sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!deletePreset " + presetId);
321 } catch (JsonSyntaxException e) {
322 logger.debug("Invalid JSON: {}", e.getMessage(), e);
325 // Invalid the presets we updated
326 requestPresets(source, updatePresetIds);
328 // Refresh our channel since 'presetJson' occupies it right now
333 * Handles any system notifications returned by the russound system
335 * @param m a non-null matcher
336 * @param resp a possibly null, possibly empty response
338 void handlePresetNotification(Matcher m, String resp) {
340 throw new IllegalArgumentException("m (matcher) cannot be null");
343 if (m.groupCount() == 5) {
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);
354 final RioPreset rioPreset = presets[source - 1][(bank - 1) * 6 + preset - 1];
358 rioPreset.setName(value);
363 rioPreset.setValid(!"false".equalsIgnoreCase(value));
368 logger.warn("Unknown preset notification: '{}'", resp);
372 logger.debug("Preset ID must be between 1 and 6: {}", resp);
375 logger.debug("Bank ID must be between 1 and 6: {}", resp);
379 logger.debug("Source ID must be between 1 and 8: {}", resp);
381 } catch (NumberFormatException e) {
382 logger.warn("Invalid Preset Notification (source/bank/preset not a parsable integer): '{}')", resp);
385 logger.warn("Invalid Preset Notification: '{}')", resp);
390 * Handles any preset notifications returned by the russound system
392 * @param m a non-null matcher
393 * @param resp a possibly null, possibly empty response
395 private void handlerSourceTypeNotification(Matcher m, String resp) {
397 throw new IllegalArgumentException("m (matcher) cannot be null");
400 if (m.groupCount() == 2) {
402 final int sourceId = Integer.parseInt(m.group(1));
403 if (sourceId >= 1 && sourceId <= 8) {
404 final String sourceType = m.group(2);
406 final Matcher matcher = IS_TUNER.matcher(sourceType);
407 final boolean srcIsTuner = matcher.matches();
409 if (srcIsTuner != isTuner[sourceId - 1]) {
410 isTuner[sourceId - 1] = srcIsTuner;
413 // force a refresh on the source
414 lastUpdateTime[sourceId - 1] = 0;
415 refreshPresets(sourceId);
417 for (int p = 0; p < 36; p++) {
418 presets[sourceId - 1][p].setValid(false);
419 presets[sourceId - 1][p].setName(null);
422 fireUpdate(sourceId);
425 logger.debug("Source is not between 1 and 8, Response: {}", resp);
427 } catch (NumberFormatException e) {
428 logger.warn("Invalid Preset Notification (source/bank/preset not a parsable integer): '{}')", resp);
431 logger.warn("Invalid Preset Notification: '{}')", resp);
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.
439 * @param a possibly null, possibly empty response
442 public void responseReceived(@Nullable String response) {
443 if (response == null || response.isEmpty()) {
447 Matcher m = RSP_PRESETNOTIFICATION.matcher(response);
449 handlePresetNotification(m, response);
452 m = RSP_SRCTYPENOTIFICATION.matcher(response);
454 handlerSourceTypeNotification(m, response);
459 * Defines the listener implementation to list for preset updates
461 * @author Tim Roberts
464 public interface Listener {
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.
469 * @param sourceId a source identifier between 1 and 8
470 * @param jsonString a non-null, non-empty json representation of {@link RioPreset}
472 void presetsUpdated(int sourceId, String jsonString);