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.source;
15 import java.util.Collections;
16 import java.util.HashMap;
18 import java.util.Objects;
19 import java.util.concurrent.atomic.AtomicInteger;
20 import java.util.concurrent.locks.Lock;
21 import java.util.concurrent.locks.ReentrantLock;
22 import java.util.regex.Matcher;
23 import java.util.regex.Pattern;
25 import org.eclipse.jdt.annotation.Nullable;
26 import org.eclipse.jetty.client.HttpClient;
27 import org.openhab.binding.russound.internal.net.SocketSession;
28 import org.openhab.binding.russound.internal.net.SocketSessionListener;
29 import org.openhab.binding.russound.internal.rio.AbstractRioProtocol;
30 import org.openhab.binding.russound.internal.rio.RioConstants;
31 import org.openhab.binding.russound.internal.rio.RioHandlerCallback;
32 import org.openhab.binding.russound.internal.rio.StatefulHandlerCallback;
33 import org.openhab.binding.russound.internal.rio.models.GsonUtilities;
34 import org.openhab.binding.russound.internal.rio.models.RioBank;
35 import org.openhab.core.library.types.StringType;
36 import org.openhab.core.types.State;
37 import org.slf4j.Logger;
38 import org.slf4j.LoggerFactory;
40 import com.google.gson.Gson;
41 import com.google.gson.JsonSyntaxException;
44 * This is the protocol handler for the Russound Source. This handler will issue the protocol commands and will
45 * process the responses from the Russound system. Please see documentation for what channels are supported by which
48 * @author Tim Roberts - Initial contribution
50 class RioSourceProtocol extends AbstractRioProtocol {
51 private final Logger logger = LoggerFactory.getLogger(RioSourceProtocol.class);
54 * The source identifier (1-12)
56 private final int source;
59 private static final String SRC_NAME = "name";
60 private static final String SRC_TYPE = "type";
61 private static final String SRC_IPADDRESS = "ipaddress";
62 private static final String SRC_COMPOSERNAME = "composername";
63 private static final String SRC_CHANNEL = "channel";
64 private static final String SRC_CHANNELNAME = "channelname";
65 private static final String SRC_GENRE = "genre";
66 private static final String SRC_ARTISTNAME = "artistname";
67 private static final String SRC_ALBUMNAME = "albumname";
68 private static final String SRC_COVERARTURL = "coverarturl";
69 private static final String SRC_PLAYLISTNAME = "playlistname";
70 private static final String SRC_SONGNAME = "songname";
71 private static final String SRC_MODE = "mode";
72 private static final String SRC_SHUFFLEMODE = "shufflemode";
73 private static final String SRC_REPEATMODE = "repeatmode";
74 private static final String SRC_RATING = "rating";
75 private static final String SRC_PROGRAMSERVICENAME = "programservicename";
76 private static final String SRC_RADIOTEXT = "radiotext";
77 private static final String SRC_RADIOTEXT2 = "radiotext2";
78 private static final String SRC_RADIOTEXT3 = "radiotext3";
79 private static final String SRC_RADIOTEXT4 = "radiotext4";
81 // Multimedia channels
82 private static final String SRC_MMSCREEN = "mmscreen";
83 private static final String SRC_MMTITLE = "mmtitle.text";
84 private static final String SRC_MMATTR = "attr";
85 private static final String SRC_MMBTNOK = "mmbtnok.text";
86 private static final String SRC_MMBTNBACK = "mmbtnback.text";
87 private static final String SRC_MMINFOBLOCK = "mminfoblock.text";
89 private static final String SRC_MMHELP = "mmhelp.text";
90 private static final String SRC_MMTEXTFIELD = "mmtextfield.text";
92 // This is an undocumented volume
93 private static final String SRC_VOLUME = "volume";
95 private static final String BANK_NAME = "name";
98 private static final Pattern RSP_MMMENUNOTIFICATION = Pattern.compile("^\\{.*\\}$");
99 private static final Pattern RSP_SRCNOTIFICATION = Pattern
100 .compile("(?i)^[SN] S\\[(\\d+)\\]\\.([a-zA-Z_0-9.\\[\\]]+)=\"(.*)\"$");
101 private static final Pattern RSP_BANKNOTIFICATION = Pattern
102 .compile("(?i)^[SN] S\\[(\\d+)\\].B\\[(\\d+)\\].(\\w+)=\"(.*)\"$");
103 private static final Pattern RSP_PRESETNOTIFICATION = Pattern
104 .compile("(?i)^[SN] S\\[(\\d+)\\].B\\[(\\d+)\\].P\\[(\\d+)\\].(\\w+)=\"(.*)\"$");
109 private final RioBank[] banks = new RioBank[6];
112 * {@link Gson} use to create/read json
114 private final Gson gson;
117 * Lock used to control access to {@link #infoText}
119 private final Lock infoLock = new ReentrantLock();
122 * The information text appeneded from media management calls
124 private final StringBuilder infoText = new StringBuilder(100);
127 * The table of channels to unique identifiers for media management functions
129 @SuppressWarnings("serial")
130 private final Map<String, AtomicInteger> mmSeqNbrs = Collections
131 .unmodifiableMap(new HashMap<String, AtomicInteger>() {
133 put(RioConstants.CHANNEL_SOURCEMMMENU, new AtomicInteger(0));
134 put(RioConstants.CHANNEL_SOURCEMMSCREEN, new AtomicInteger(0));
135 put(RioConstants.CHANNEL_SOURCEMMTITLE, new AtomicInteger(0));
136 put(RioConstants.CHANNEL_SOURCEMMATTR, new AtomicInteger(0));
137 put(RioConstants.CHANNEL_SOURCEMMBUTTONOKTEXT, new AtomicInteger(0));
138 put(RioConstants.CHANNEL_SOURCEMMBUTTONBACKTEXT, new AtomicInteger(0));
139 put(RioConstants.CHANNEL_SOURCEMMINFOTEXT, new AtomicInteger(0));
140 put(RioConstants.CHANNEL_SOURCEMMHELPTEXT, new AtomicInteger(0));
141 put(RioConstants.CHANNEL_SOURCEMMTEXTFIELD, new AtomicInteger(0));
146 * The client used for http requests
148 private final HttpClient httpClient;
151 * Constructs the protocol handler from given parameters
153 * @param source the source identifier
154 * @param session a non-null {@link SocketSession} (may be connected or disconnected)
155 * @param callback a non-null {@link RioHandlerCallback} to callback
156 * @throws Exception exception when starting the {@link HttpClient}
158 RioSourceProtocol(int source, SocketSession session, RioHandlerCallback callback) throws Exception {
159 super(session, callback);
160 if (source < 1 || source > 12) {
161 throw new IllegalArgumentException("Source must be between 1-12: " + source);
163 this.source = source;
164 httpClient = new HttpClient();
165 httpClient.setFollowRedirects(true);
168 gson = GsonUtilities.createGson();
170 for (int x = 1; x <= 6; x++) {
171 banks[x - 1] = new RioBank(x);
176 * Helper method to issue post online commands
180 refreshSourceIpAddress();
183 updateBanksChannel();
187 * Helper method to refresh a source key
189 * @param keyName a non-null, non-empty source key to refresh
190 * @throws IllegalArgumentException if keyName is null or empty
192 private void refreshSourceKey(String keyName) {
193 if (keyName == null || keyName.trim().length() == 0) {
194 throw new IllegalArgumentException("keyName cannot be null or empty");
196 sendCommand("GET S[" + source + "]." + keyName);
200 * Refreshes the source name
202 void refreshSourceName() {
203 refreshSourceKey(SRC_NAME);
207 * Refresh the source model type
209 void refreshSourceType() {
210 refreshSourceKey(SRC_TYPE);
214 * Refresh the source ip address
216 void refreshSourceIpAddress() {
217 refreshSourceKey(SRC_IPADDRESS);
221 * Refresh composer name
223 void refreshSourceComposerName() {
224 refreshSourceKey(SRC_COMPOSERNAME);
228 * Refresh the channel frequency (for tuners)
230 void refreshSourceChannel() {
231 refreshSourceKey(SRC_CHANNEL);
235 * Refresh the channel's name
237 void refreshSourceChannelName() {
238 refreshSourceKey(SRC_CHANNELNAME);
242 * Refresh the song's genre
244 void refreshSourceGenre() {
245 refreshSourceKey(SRC_GENRE);
249 * Refresh the artist name
251 void refreshSourceArtistName() {
252 refreshSourceKey(SRC_ARTISTNAME);
256 * Refresh the album name
258 void refreshSourceAlbumName() {
259 refreshSourceKey(SRC_ALBUMNAME);
263 * Refresh the cover art URL
265 void refreshSourceCoverArtUrl() {
266 refreshSourceKey(SRC_COVERARTURL);
270 * Refresh the playlist name
272 void refreshSourcePlaylistName() {
273 refreshSourceKey(SRC_PLAYLISTNAME);
277 * Refresh the song name
279 void refreshSourceSongName() {
280 refreshSourceKey(SRC_SONGNAME);
284 * Refresh the provider mode/streaming service
286 void refreshSourceMode() {
287 refreshSourceKey(SRC_MODE);
291 * Refresh the shuffle mode
293 void refreshSourceShuffleMode() {
294 refreshSourceKey(SRC_SHUFFLEMODE);
298 * Refresh the repeat mode
300 void refreshSourceRepeatMode() {
301 refreshSourceKey(SRC_REPEATMODE);
305 * Refresh the rating of the song
307 void refreshSourceRating() {
308 refreshSourceKey(SRC_RATING);
312 * Refresh the program service name
314 void refreshSourceProgramServiceName() {
315 refreshSourceKey(SRC_PROGRAMSERVICENAME);
319 * Refresh the radio text
321 void refreshSourceRadioText() {
322 refreshSourceKey(SRC_RADIOTEXT);
326 * Refresh the radio text (line #2)
328 void refreshSourceRadioText2() {
329 refreshSourceKey(SRC_RADIOTEXT2);
333 * Refresh the radio text (line #3)
335 void refreshSourceRadioText3() {
336 refreshSourceKey(SRC_RADIOTEXT3);
340 * Refresh the radio text (line #4)
342 void refreshSourceRadioText4() {
343 refreshSourceKey(SRC_RADIOTEXT4);
347 * Refresh the source volume
349 void refreshSourceVolume() {
350 refreshSourceKey(SRC_VOLUME);
354 * Refreshes the names of the banks
356 void refreshBanks() {
357 for (int b = 1; b <= 6; b++) {
358 sendCommand("GET S[" + source + "].B[" + b + "]." + BANK_NAME);
363 * Sets the bank names from the supplied bank JSON and returns a runnable to call {@link #updateBanksChannel()}
365 * @param bankJson a possibly null, possibly empty json containing the {@link RioBank} to update
366 * @return a non-null {@link Runnable} to execute after this call
368 Runnable setBanks(String bankJson) {
369 // If null or empty - simply return a do nothing runnable
370 if (bankJson == null || bankJson.isEmpty()) {
376 final RioBank[] newBanks;
377 newBanks = gson.fromJson(bankJson, RioBank[].class);
378 for (int x = 0; x < newBanks.length; x++) {
379 final RioBank bank = newBanks[x];
381 continue; // caused by {id,valid,name},,{id,valid,name}
384 final int bankId = bank.getId();
385 if (bankId < 1 || bankId > 6) {
386 logger.debug("Invalid bank id (not between 1 and 6) - ignoring: {}:{}", bankId, bankJson);
388 final RioBank myBank = banks[bankId - 1];
390 if (!Objects.equals(myBank.getName(), bank.getName())) {
391 myBank.setName(bank.getName());
393 "SET S[" + source + "].B[" + bankId + "]." + BANK_NAME + "=\"" + bank.getName() + "\"");
397 } catch (JsonSyntaxException e) {
398 logger.debug("Invalid JSON: {}", e.getMessage(), e);
401 // regardless of what happens above - reupdate the channel
402 // (to remove anything bad from it)
403 return this::updateBanksChannel;
407 * Helper method to simply update the banks channel. Will create a JSON representation from {@link #banks} and send
410 private void updateBanksChannel() {
411 final String bankJson = gson.toJson(banks);
412 stateChanged(RioConstants.CHANNEL_SOURCEBANKS, new StringType(bankJson));
416 * Turns on/off watching the source for notifications
418 * @param watch true to turn on, false to turn off
420 void watchSource(boolean watch) {
421 sendCommand("WATCH S[" + source + "] " + (watch ? "ON" : "OFF"));
425 * Helper method to handle any media management change. If the channel is the INFO text channel, we delegate to
426 * {@link #handleMMInfoText(String)} instead. This helper method will simply get the next MM identifier and send the
427 * json representation out for the channel change (this ensures unique messages for each MM notification)
429 * @param channelId a non-null, non-empty channelId
430 * @param value the value for the channel
431 * @throws IllegalArgumentException if channelID is null or empty
433 private void handleMMChange(String channelId, String value) {
434 if (channelId == null || channelId.isEmpty()) {
435 throw new IllegalArgumentException("channelId cannot be null or empty");
438 final AtomicInteger ai = mmSeqNbrs.get(channelId);
440 logger.error("Channel {} does not have an ID configuration - programmer error!", channelId);
442 if (channelId.equals(RioConstants.CHANNEL_SOURCEMMINFOTEXT)) {
443 value = handleMMInfoText(value);
449 final int id = ai.getAndIncrement();
451 final String json = gson.toJson(new IdValue(id, value));
452 stateChanged(channelId, new StringType(json));
457 * Helper method to handle MMInfoText notifications. There may be multiple infotext messages that represent a single
458 * message. We know when we get the last info text when the MMATTR contains an 'E' (last item). Once we have the
459 * last item, we update the channel with the complete message.
461 * @param infoTextValue the last info text value
462 * @return a non-null containing the complete or null if the message isn't complete yet
464 private String handleMMInfoText(String infoTextValue) {
465 final StatefulHandlerCallback callback = ((StatefulHandlerCallback) getCallback());
467 final State attr = callback.getProperty(RioConstants.CHANNEL_SOURCEMMATTR);
471 infoText.append(infoTextValue);
472 if (attr != null && attr.toString().contains("E")) {
473 final String text = infoText.toString();
475 infoText.setLength(0);
476 callback.removeState(RioConstants.CHANNEL_SOURCEMMATTR);
487 * Handles any source notifications returned by the russound system
489 * @param m a non-null matcher
490 * @param resp a possibly null, possibly empty response
492 private void handleSourceNotification(Matcher m, String resp) {
494 throw new IllegalArgumentException("m (matcher) cannot be null");
496 if (m.groupCount() == 3) {
498 final int notifySource = Integer.parseInt(m.group(1));
499 if (notifySource != source) {
502 final String key = m.group(2).toLowerCase();
503 final String value = m.group(3);
507 stateChanged(RioConstants.CHANNEL_SOURCENAME, new StringType(value));
511 stateChanged(RioConstants.CHANNEL_SOURCETYPE, new StringType(value));
515 setProperty(RioConstants.PROPERTY_SOURCEIPADDRESS, value);
518 case SRC_COMPOSERNAME:
519 stateChanged(RioConstants.CHANNEL_SOURCECOMPOSERNAME, new StringType(value));
523 stateChanged(RioConstants.CHANNEL_SOURCECHANNEL, new StringType(value));
526 case SRC_CHANNELNAME:
527 stateChanged(RioConstants.CHANNEL_SOURCECHANNELNAME, new StringType(value));
531 stateChanged(RioConstants.CHANNEL_SOURCEGENRE, new StringType(value));
535 stateChanged(RioConstants.CHANNEL_SOURCEARTISTNAME, new StringType(value));
539 stateChanged(RioConstants.CHANNEL_SOURCEALBUMNAME, new StringType(value));
542 case SRC_COVERARTURL:
543 stateChanged(RioConstants.CHANNEL_SOURCECOVERARTURL, new StringType(value));
546 case SRC_PLAYLISTNAME:
547 stateChanged(RioConstants.CHANNEL_SOURCEPLAYLISTNAME, new StringType(value));
551 stateChanged(RioConstants.CHANNEL_SOURCESONGNAME, new StringType(value));
555 stateChanged(RioConstants.CHANNEL_SOURCEMODE, new StringType(value));
558 case SRC_SHUFFLEMODE:
559 stateChanged(RioConstants.CHANNEL_SOURCESHUFFLEMODE, new StringType(value));
563 stateChanged(RioConstants.CHANNEL_SOURCEREPEATMODE, new StringType(value));
567 stateChanged(RioConstants.CHANNEL_SOURCERATING, new StringType(value));
570 case SRC_PROGRAMSERVICENAME:
571 stateChanged(RioConstants.CHANNEL_SOURCEPROGRAMSERVICENAME, new StringType(value));
575 stateChanged(RioConstants.CHANNEL_SOURCERADIOTEXT, new StringType(value));
579 stateChanged(RioConstants.CHANNEL_SOURCERADIOTEXT2, new StringType(value));
583 stateChanged(RioConstants.CHANNEL_SOURCERADIOTEXT3, new StringType(value));
587 stateChanged(RioConstants.CHANNEL_SOURCERADIOTEXT4, new StringType(value));
591 stateChanged(RioConstants.CHANNEL_SOURCEVOLUME, new StringType(value));
595 handleMMChange(RioConstants.CHANNEL_SOURCEMMSCREEN, value);
599 handleMMChange(RioConstants.CHANNEL_SOURCEMMTITLE, value);
603 handleMMChange(RioConstants.CHANNEL_SOURCEMMATTR, value);
607 handleMMChange(RioConstants.CHANNEL_SOURCEMMBUTTONOKTEXT, value);
611 handleMMChange(RioConstants.CHANNEL_SOURCEMMBUTTONBACKTEXT, value);
615 handleMMChange(RioConstants.CHANNEL_SOURCEMMHELPTEXT, value);
618 case SRC_MMTEXTFIELD:
619 handleMMChange(RioConstants.CHANNEL_SOURCEMMTEXTFIELD, value);
622 case SRC_MMINFOBLOCK:
623 handleMMChange(RioConstants.CHANNEL_SOURCEMMINFOTEXT, value);
626 logger.warn("Unknown source notification: '{}'", resp);
629 } catch (NumberFormatException e) {
630 logger.warn("Invalid Source Notification (source not a parsable integer): '{}')", resp);
633 logger.warn("Invalid Source Notification response: '{}'", resp);
638 * Handles any bank notifications returned by the russound system
640 * @param m a non-null matcher
641 * @param resp a possibly null, possibly empty response
643 private void handleBankNotification(Matcher m, String resp) {
645 throw new IllegalArgumentException("m (matcher) cannot be null");
648 // System notification
649 if (m.groupCount() == 4) {
651 final int bank = Integer.parseInt(m.group(2));
652 if (bank >= 1 && bank <= 6) {
653 final int notifySource = Integer.parseInt(m.group(1));
654 if (notifySource != source) {
658 final String key = m.group(3).toLowerCase();
659 final String value = m.group(4);
663 banks[bank - 1].setName(value);
664 updateBanksChannel();
668 logger.warn("Unknown bank name notification: '{}'", resp);
672 logger.debug("Bank ID must be between 1 and 6: {}", resp);
675 } catch (NumberFormatException e) {
676 logger.warn("Invalid Bank Name Notification (bank/source not a parsable integer): '{}')", resp);
680 logger.warn("Invalid Bank Notification: '{}')", resp);
685 * Implements {@link SocketSessionListener#responseReceived(String)} to try to process the response from the
686 * russound system. This response may be for other protocol handler - so ignore if we don't recognize the response.
688 * @param a possibly null, possibly empty response
691 public void responseReceived(@Nullable String response) {
692 if (response == null || response.isEmpty()) {
696 Matcher m = RSP_BANKNOTIFICATION.matcher(response);
698 handleBankNotification(m, response);
702 m = RSP_PRESETNOTIFICATION.matcher(response);
708 m = RSP_SRCNOTIFICATION.matcher(response);
710 handleSourceNotification(m, response);
713 m = RSP_MMMENUNOTIFICATION.matcher(response);
716 handleMMChange(RioConstants.CHANNEL_SOURCEMMMENU, response);
717 } catch (NumberFormatException e) {
718 logger.debug("Could not parse the menu text (1) from {}", response);
724 * Overrides the default implementation to turn watch off ({@link #watchSource(boolean)}) before calling the dispose
727 public void dispose() {
729 if (httpClient != null) {
732 } catch (Exception e) {
733 logger.debug("Error stopping the httpclient", e);
740 * The following class is simply used as a model for an id/value combination that will be serialized to JSON.
741 * Nothing needs to be public because the serialization walks the properties.
743 * @author Tim Roberts
746 @SuppressWarnings("unused")
747 private class IdValue {
748 /** The id of the value */
749 private final int id;
751 /** The value for the id */
752 private final String value;
755 * Constructions ID/Value from the given parms (no validations are done)
757 * @param id the identifier
758 * @param value the associated value
760 public IdValue(int id, String value) {