org.openhab.binding.russound.internal.rio.source.RioSourceProtocol.java Source code

Java tutorial

Introduction

Here is the source code for org.openhab.binding.russound.internal.rio.source.RioSourceProtocol.java

Source

/**
 * Copyright (c) 2010-2017 by the respective copyright holders.
 *
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 */
package org.openhab.binding.russound.internal.rio.source;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.commons.lang.NullArgumentException;
import org.apache.commons.lang.StringUtils;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.smarthome.core.library.types.StringType;
import org.eclipse.smarthome.core.types.State;
import org.openhab.binding.russound.internal.net.SocketSession;
import org.openhab.binding.russound.internal.net.SocketSessionListener;
import org.openhab.binding.russound.internal.rio.AbstractRioProtocol;
import org.openhab.binding.russound.internal.rio.RioConstants;
import org.openhab.binding.russound.internal.rio.RioHandlerCallback;
import org.openhab.binding.russound.internal.rio.StatefulHandlerCallback;
import org.openhab.binding.russound.internal.rio.models.GsonUtilities;
import org.openhab.binding.russound.internal.rio.models.RioBank;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;

/**
 * This is the protocol handler for the Russound Source. This handler will issue the protocol commands and will
 * process the responses from the Russound system. Please see documentation for what channels are supported by which
 * source types.
 *
 * @author Tim Roberts
 *
 */
class RioSourceProtocol extends AbstractRioProtocol {
    private final Logger logger = LoggerFactory.getLogger(RioSourceProtocol.class);

    /**
     * The source identifier (1-12)
     */
    private final int source;

    // Protocol constants
    private static final String SRC_NAME = "name";
    private static final String SRC_TYPE = "type";
    private static final String SRC_IPADDRESS = "ipaddress";
    private static final String SRC_COMPOSERNAME = "composername";
    private static final String SRC_CHANNEL = "channel";
    private static final String SRC_CHANNELNAME = "channelname";
    private static final String SRC_GENRE = "genre";
    private static final String SRC_ARTISTNAME = "artistname";
    private static final String SRC_ALBUMNAME = "albumname";
    private static final String SRC_COVERARTURL = "coverarturl";
    private static final String SRC_PLAYLISTNAME = "playlistname";
    private static final String SRC_SONGNAME = "songname";
    private static final String SRC_MODE = "mode";
    private static final String SRC_SHUFFLEMODE = "shufflemode";
    private static final String SRC_REPEATMODE = "repeatmode";
    private static final String SRC_RATING = "rating";
    private static final String SRC_PROGRAMSERVICENAME = "programservicename";
    private static final String SRC_RADIOTEXT = "radiotext";
    private static final String SRC_RADIOTEXT2 = "radiotext2";
    private static final String SRC_RADIOTEXT3 = "radiotext3";
    private static final String SRC_RADIOTEXT4 = "radiotext4";

    // Multimedia channels
    private static final String SRC_MMScreen = "mmscreen";
    private static final String SRC_MMTitle = "mmtitle.text";
    private static final String SRC_MMAttr = "attr";
    private static final String SRC_MMBtnOk = "mmbtnok.text";
    private static final String SRC_MMBtnBack = "mmbtnback.text";
    private static final String SRC_MMInfoBlock = "mminfoblock.text";

    private static final String SRC_MMHelp = "mmhelp.text";
    private static final String SRC_MMTextField = "mmtextfield.text";

    // This is an undocumented volume
    private static final String SRC_VOLUME = "volume";

    private static final String BANK_NAME = "name";

    // Response patterns
    private static final Pattern RSP_MMMENUNOTIFICATION = Pattern.compile("^\\{.*\\}$");
    private static final Pattern RSP_SRCNOTIFICATION = Pattern
            .compile("(?i)^[SN] S\\[(\\d+)\\]\\.([a-zA-Z_0-9.\\[\\]]+)=\"(.*)\"$");
    private static final Pattern RSP_BANKNOTIFICATION = Pattern
            .compile("(?i)^[SN] S\\[(\\d+)\\].B\\[(\\d+)\\].(\\w+)=\"(.*)\"$");
    private static final Pattern RSP_PRESETNOTIFICATION = Pattern
            .compile("(?i)^[SN] S\\[(\\d+)\\].B\\[(\\d+)\\].P\\[(\\d+)\\].(\\w+)=\"(.*)\"$");

    /**
     * Current banks
     */
    private final RioBank[] banks = new RioBank[6];

    /**
     * {@link Gson} use to create/read json
     */
    private final Gson gson;

    /**
     * Lock used to control access to {@link #infoText}
     */
    private final Lock infoLock = new ReentrantLock();

    /**
     * The information text appeneded from media management calls
     */
    private final StringBuilder infoText = new StringBuilder(100);

    /**
     * The table of channels to unique identifiers for media management functions
     */
    @SuppressWarnings("serial")
    private final Map<String, AtomicInteger> mmSeqNbrs = Collections
            .unmodifiableMap(new HashMap<String, AtomicInteger>() {
                {
                    put(RioConstants.CHANNEL_SOURCEMMMENU, new AtomicInteger(0));
                    put(RioConstants.CHANNEL_SOURCEMMSCREEN, new AtomicInteger(0));
                    put(RioConstants.CHANNEL_SOURCEMMTITLE, new AtomicInteger(0));
                    put(RioConstants.CHANNEL_SOURCEMMATTR, new AtomicInteger(0));
                    put(RioConstants.CHANNEL_SOURCEMMBUTTONOKTEXT, new AtomicInteger(0));
                    put(RioConstants.CHANNEL_SOURCEMMBUTTONBACKTEXT, new AtomicInteger(0));
                    put(RioConstants.CHANNEL_SOURCEMMINFOTEXT, new AtomicInteger(0));
                    put(RioConstants.CHANNEL_SOURCEMMHELPTEXT, new AtomicInteger(0));
                    put(RioConstants.CHANNEL_SOURCEMMTEXTFIELD, new AtomicInteger(0));
                }
            });

    /**
     * The client used for http requests
     */
    private final HttpClient httpClient;

    /**
     * Constructs the protocol handler from given parameters
     *
     * @param source the source identifier
     * @param session a non-null {@link SocketSession} (may be connected or disconnected)
     * @param callback a non-null {@link RioHandlerCallback} to callback
     * @throws Exception exception when starting the {@link HttpClient}
     */
    RioSourceProtocol(int source, SocketSession session, RioHandlerCallback callback) throws Exception {
        super(session, callback);
        if (source < 1 || source > 12) {
            throw new IllegalArgumentException("Source must be between 1-12: " + source);
        }
        this.source = source;
        httpClient = new HttpClient();
        httpClient.setFollowRedirects(true);
        httpClient.start();

        gson = GsonUtilities.createGson();

        for (int x = 1; x <= 6; x++) {
            banks[x - 1] = new RioBank(x);
        }
    }

    /**
     * Helper method to issue post online commands
     */
    void postOnline() {
        watchSource(true);
        refreshSourceIpAddress();
        refreshSourceName();

        updateBanksChannel();
    }

    /**
     * Helper method to refresh a source key
     *
     * @param keyName a non-null, non-empty source key to refresh
     * @throws IllegalArgumentException if keyName is null or empty
     */
    private void refreshSourceKey(String keyName) {
        if (keyName == null || keyName.trim().length() == 0) {
            throw new IllegalArgumentException("keyName cannot be null or empty");
        }
        sendCommand("GET S[" + source + "]." + keyName);
    }

    /**
     * Refreshes the source name
     */
    void refreshSourceName() {
        refreshSourceKey(SRC_NAME);
    }

    /**
     * Refresh the source model type
     */
    void refreshSourceType() {
        refreshSourceKey(SRC_TYPE);
    }

    /**
     * Refresh the source ip address
     */
    void refreshSourceIpAddress() {
        refreshSourceKey(SRC_IPADDRESS);
    }

    /**
     * Refresh composer name
     */
    void refreshSourceComposerName() {
        refreshSourceKey(SRC_COMPOSERNAME);
    }

    /**
     * Refresh the channel frequency (for tuners)
     */
    void refreshSourceChannel() {
        refreshSourceKey(SRC_CHANNEL);
    }

    /**
     * Refresh the channel's name
     */
    void refreshSourceChannelName() {
        refreshSourceKey(SRC_CHANNELNAME);
    }

    /**
     * Refresh the song's genre
     */
    void refreshSourceGenre() {
        refreshSourceKey(SRC_GENRE);
    }

    /**
     * Refresh the artist name
     */
    void refreshSourceArtistName() {
        refreshSourceKey(SRC_ARTISTNAME);
    }

    /**
     * Refresh the album name
     */
    void refreshSourceAlbumName() {
        refreshSourceKey(SRC_ALBUMNAME);
    }

    /**
     * Refresh the cover art URL
     */
    void refreshSourceCoverArtUrl() {
        refreshSourceKey(SRC_COVERARTURL);
    }

    /**
     * Refresh the playlist name
     */
    void refreshSourcePlaylistName() {
        refreshSourceKey(SRC_PLAYLISTNAME);
    }

    /**
     * Refresh the song name
     */
    void refreshSourceSongName() {
        refreshSourceKey(SRC_SONGNAME);
    }

    /**
     * Refresh the provider mode/streaming service
     */
    void refreshSourceMode() {
        refreshSourceKey(SRC_MODE);
    }

    /**
     * Refresh the shuffle mode
     */
    void refreshSourceShuffleMode() {
        refreshSourceKey(SRC_SHUFFLEMODE);
    }

    /**
     * Refresh the repeat mode
     */
    void refreshSourceRepeatMode() {
        refreshSourceKey(SRC_REPEATMODE);
    }

    /**
     * Refresh the rating of the song
     */
    void refreshSourceRating() {
        refreshSourceKey(SRC_RATING);
    }

    /**
     * Refresh the program service name
     */
    void refreshSourceProgramServiceName() {
        refreshSourceKey(SRC_PROGRAMSERVICENAME);
    }

    /**
     * Refresh the radio text
     */
    void refreshSourceRadioText() {
        refreshSourceKey(SRC_RADIOTEXT);
    }

    /**
     * Refresh the radio text (line #2)
     */
    void refreshSourceRadioText2() {
        refreshSourceKey(SRC_RADIOTEXT2);
    }

    /**
     * Refresh the radio text (line #3)
     */
    void refreshSourceRadioText3() {
        refreshSourceKey(SRC_RADIOTEXT3);
    }

    /**
     * Refresh the radio text (line #4)
     */
    void refreshSourceRadioText4() {
        refreshSourceKey(SRC_RADIOTEXT4);
    }

    /**
     * Refresh the source volume
     */
    void refreshSourceVolume() {
        refreshSourceKey(SRC_VOLUME);
    }

    /**
     * Refreshes the names of the banks
     */
    void refreshBanks() {
        for (int b = 1; b <= 6; b++) {
            sendCommand("GET S[" + source + "].B[" + b + "]." + BANK_NAME);
        }
    }

    /**
     * Sets the bank names from the supplied bank JSON and returns a runnable to call {@link #updateBanksChannel()}
     *
     * @param bankJson a possibly null, possibly empty json containing the {@link RioBank} to update
     * @return a non-null {@link Runnable} to execute after this call
     */
    Runnable setBanks(String bankJson) {

        // If null or empty - simply return a do nothing runnable
        if (StringUtils.isEmpty(bankJson)) {
            return new Runnable() {
                @Override
                public void run() {
                }
            };
        }

        try {
            final RioBank[] newBanks;
            newBanks = gson.fromJson(bankJson, RioBank[].class);
            for (int x = 0; x < newBanks.length; x++) {
                final RioBank bank = newBanks[x];
                if (bank == null) {
                    continue; // caused by {id,valid,name},,{id,valid,name}
                }

                final int bankId = bank.getId();
                if (bankId < 1 || bankId > 6) {
                    logger.debug("Invalid bank id (not between 1 and 6) - ignoring: {}:{}", bankId, bankJson);
                } else {
                    final RioBank myBank = banks[bankId - 1];

                    if (!StringUtils.equals(myBank.getName(), bank.getName())) {
                        myBank.setName(bank.getName());
                        sendCommand("SET S[" + source + "].B[" + bankId + "]." + BANK_NAME + "=\"" + bank.getName()
                                + "\"");
                    }
                }
            }
        } catch (JsonSyntaxException e) {
            logger.debug("Invalid JSON: {}", e.getMessage(), e);
        }

        // regardless of what happens above - reupdate the channel
        // (to remove anything bad from it)
        return new Runnable() {
            @Override
            public void run() {
                updateBanksChannel();
            }
        };
    }

    /**
     * Helper method to simply update the banks channel. Will create a JSON representation from {@link #banks} and send
     * it via the channel
     */
    private void updateBanksChannel() {
        final String bankJson = gson.toJson(banks);
        stateChanged(RioConstants.CHANNEL_SOURCEBANKS, new StringType(bankJson));
    }

    /**
     * Turns on/off watching the source for notifications
     *
     * @param watch true to turn on, false to turn off
     */
    void watchSource(boolean watch) {
        sendCommand("WATCH S[" + source + "] " + (watch ? "ON" : "OFF"));
    }

    /**
     * Helper method to handle any media management change. If the channel is the INFO text channel, we delegate to
     * {@link #handleMMInfoText(String)} instead. This helper method will simply get the next MM identifier and send the
     * json representation out for the channel change (this ensures unique messages for each MM notification)
     *
     * @param channelId a non-null, non-empty channelId
     * @param value the value for the channel
     * @throws IllegalArgumentException if channelID is null or empty
     */
    private void handleMMChange(String channelId, String value) {
        if (StringUtils.isEmpty(channelId)) {
            throw new NullArgumentException("channelId cannot be null or empty");
        }

        final AtomicInteger ai = mmSeqNbrs.get(channelId);
        if (ai == null) {
            logger.error("Channel {} does not have an ID configuration - programmer error!", channelId);
        } else {

            if (channelId.equals(RioConstants.CHANNEL_SOURCEMMINFOTEXT)) {
                value = handleMMInfoText(value);
                if (value == null) {
                    return;
                }
            }

            final int id = ai.getAndIncrement();

            final String json = gson.toJson(new IdValue(id, value));
            stateChanged(channelId, new StringType(json));
        }
    }

    /**
     * Helper method to handle MMInfoText notifications. There may be multiple infotext messages that represent a single
     * message. We know when we get the last info text when the MMATTR contains an 'E' (last item). Once we have the
     * last item, we update the channel with the complete message.
     *
     * @param infoTextValue the last info text value
     * @return a non-null containing the complete or null if the message isn't complete yet
     */
    private String handleMMInfoText(String infoTextValue) {
        final StatefulHandlerCallback callback = ((StatefulHandlerCallback) getCallback());

        final State attr = callback.getProperty(RioConstants.CHANNEL_SOURCEMMATTR);

        infoLock.lock();
        try {
            infoText.append(infoTextValue.toString());
            if (attr != null && attr.toString().indexOf("E") >= 0) {
                final String text = infoText.toString();

                infoText.setLength(0);
                callback.removeState(RioConstants.CHANNEL_SOURCEMMATTR);

                return text;
            }
            return null;
        } finally {
            infoLock.unlock();
        }
    }

    /**
     * Handles any source notifications returned by the russound system
     *
     * @param m a non-null matcher
     * @param resp a possibly null, possibly empty response
     */
    private void handleSourceNotification(Matcher m, String resp) {
        if (m == null) {
            throw new IllegalArgumentException("m (matcher) cannot be null");
        }
        if (m.groupCount() == 3) {
            try {
                final int notifySource = Integer.parseInt(m.group(1));
                if (notifySource != source) {
                    return;
                }
                final String key = m.group(2).toLowerCase();
                final String value = m.group(3);

                switch (key) {
                case SRC_NAME:
                    stateChanged(RioConstants.CHANNEL_SOURCENAME, new StringType(value));
                    break;

                case SRC_TYPE:
                    stateChanged(RioConstants.CHANNEL_SOURCETYPE, new StringType(value));
                    break;

                case SRC_IPADDRESS:
                    setProperty(RioConstants.PROPERTY_SOURCEIPADDRESS, value);
                    break;

                case SRC_COMPOSERNAME:
                    stateChanged(RioConstants.CHANNEL_SOURCECOMPOSERNAME, new StringType(value));
                    break;

                case SRC_CHANNEL:
                    stateChanged(RioConstants.CHANNEL_SOURCECHANNEL, new StringType(value));
                    break;

                case SRC_CHANNELNAME:
                    stateChanged(RioConstants.CHANNEL_SOURCECHANNELNAME, new StringType(value));
                    break;

                case SRC_GENRE:
                    stateChanged(RioConstants.CHANNEL_SOURCEGENRE, new StringType(value));
                    break;

                case SRC_ARTISTNAME:
                    stateChanged(RioConstants.CHANNEL_SOURCEARTISTNAME, new StringType(value));
                    break;

                case SRC_ALBUMNAME:
                    stateChanged(RioConstants.CHANNEL_SOURCEALBUMNAME, new StringType(value));
                    break;

                case SRC_COVERARTURL:
                    stateChanged(RioConstants.CHANNEL_SOURCECOVERARTURL, new StringType(value));
                    break;

                case SRC_PLAYLISTNAME:
                    stateChanged(RioConstants.CHANNEL_SOURCEPLAYLISTNAME, new StringType(value));
                    break;

                case SRC_SONGNAME:
                    stateChanged(RioConstants.CHANNEL_SOURCESONGNAME, new StringType(value));
                    break;

                case SRC_MODE:
                    stateChanged(RioConstants.CHANNEL_SOURCEMODE, new StringType(value));
                    break;

                case SRC_SHUFFLEMODE:
                    stateChanged(RioConstants.CHANNEL_SOURCESHUFFLEMODE, new StringType(value));
                    break;

                case SRC_REPEATMODE:
                    stateChanged(RioConstants.CHANNEL_SOURCEREPEATMODE, new StringType(value));
                    break;

                case SRC_RATING:
                    stateChanged(RioConstants.CHANNEL_SOURCERATING, new StringType(value));
                    break;

                case SRC_PROGRAMSERVICENAME:
                    stateChanged(RioConstants.CHANNEL_SOURCEPROGRAMSERVICENAME, new StringType(value));
                    break;

                case SRC_RADIOTEXT:
                    stateChanged(RioConstants.CHANNEL_SOURCERADIOTEXT, new StringType(value));
                    break;

                case SRC_RADIOTEXT2:
                    stateChanged(RioConstants.CHANNEL_SOURCERADIOTEXT2, new StringType(value));
                    break;

                case SRC_RADIOTEXT3:
                    stateChanged(RioConstants.CHANNEL_SOURCERADIOTEXT3, new StringType(value));
                    break;

                case SRC_RADIOTEXT4:
                    stateChanged(RioConstants.CHANNEL_SOURCERADIOTEXT4, new StringType(value));
                    break;

                case SRC_VOLUME:
                    stateChanged(RioConstants.CHANNEL_SOURCEVOLUME, new StringType(value));
                    break;

                case SRC_MMScreen:
                    handleMMChange(RioConstants.CHANNEL_SOURCEMMSCREEN, value);
                    break;

                case SRC_MMTitle:
                    handleMMChange(RioConstants.CHANNEL_SOURCEMMTITLE, value);
                    break;

                case SRC_MMAttr:
                    handleMMChange(RioConstants.CHANNEL_SOURCEMMATTR, value);
                    break;

                case SRC_MMBtnOk:
                    handleMMChange(RioConstants.CHANNEL_SOURCEMMBUTTONOKTEXT, value);
                    break;

                case SRC_MMBtnBack:
                    handleMMChange(RioConstants.CHANNEL_SOURCEMMBUTTONBACKTEXT, value);
                    break;

                case SRC_MMHelp:
                    handleMMChange(RioConstants.CHANNEL_SOURCEMMHELPTEXT, value);
                    break;

                case SRC_MMTextField:
                    handleMMChange(RioConstants.CHANNEL_SOURCEMMTEXTFIELD, value);
                    break;

                case SRC_MMInfoBlock:
                    handleMMChange(RioConstants.CHANNEL_SOURCEMMINFOTEXT, value);
                    break;
                default:
                    logger.warn("Unknown source notification: '{}'", resp);
                    break;
                }
            } catch (NumberFormatException e) {
                logger.warn("Invalid Source Notification (source not a parsable integer): '{}')", resp);
            }
        } else {
            logger.warn("Invalid Source Notification response: '{}'", resp);
        }

    }

    /**
     * Handles any bank notifications returned by the russound system
     *
     * @param m a non-null matcher
     * @param resp a possibly null, possibly empty response
     */
    private void handleBankNotification(Matcher m, String resp) {
        if (m == null) {
            throw new IllegalArgumentException("m (matcher) cannot be null");
        }

        // System notification
        if (m.groupCount() == 4) {
            try {
                final int bank = Integer.parseInt(m.group(2));
                if (bank >= 1 && bank <= 6) {
                    final int notifySource = Integer.parseInt(m.group(1));
                    if (notifySource != source) {
                        return;
                    }

                    final String key = m.group(3).toLowerCase();
                    final String value = m.group(4);

                    switch (key) {
                    case BANK_NAME:
                        banks[bank - 1].setName(value);
                        updateBanksChannel();
                        break;

                    default:
                        logger.warn("Unknown bank name notification: '{}'", resp);
                        break;
                    }
                } else {
                    logger.debug("Bank ID must be between 1 and 6: {}", resp);
                }

            } catch (NumberFormatException e) {
                logger.warn("Invalid Bank Name Notification (bank/source not a parsable integer): '{}')", resp);
            }

        } else {
            logger.warn("Invalid Bank Notification: '{}')", resp);
        }
    }

    /**
     * Implements {@link SocketSessionListener#responseReceived(String)} to try to process the response from the
     * russound system. This response may be for other protocol handler - so ignore if we don't recognize the response.
     *
     * @param a possibly null, possibly empty response
     */
    @Override
    public void responseReceived(String response) {
        if (StringUtils.isEmpty(response)) {
            return;
        }

        Matcher m = RSP_BANKNOTIFICATION.matcher(response);
        if (m.matches()) {
            handleBankNotification(m, response);
            return;
        }

        m = RSP_PRESETNOTIFICATION.matcher(response);
        if (m.matches()) {
            // does nothing
            return;
        }

        m = RSP_SRCNOTIFICATION.matcher(response);
        if (m.matches()) {
            handleSourceNotification(m, response);
        }

        m = RSP_MMMENUNOTIFICATION.matcher(response);
        if (m.matches()) {
            try {
                handleMMChange(RioConstants.CHANNEL_SOURCEMMMENU, response);
            } catch (NumberFormatException e) {
                logger.debug("Could not parse the menu text (1) from {}", response);
            }
        }
    }

    /**
     * Overrides the default implementation to turn watch off ({@link #watchSource(boolean)}) before calling the dispose
     */
    @Override
    public void dispose() {
        watchSource(false);
        if (httpClient != null) {
            try {
                httpClient.stop();
            } catch (Exception e) {
                logger.debug("Error stopping the httpclient: {}", e);
            }
        }
        super.dispose();
    }

    /**
     * The following class is simply used as a model for an id/value combination that will be serialized to JSON.
     * Nothing needs to be public because the serialization walks the properties.
     *
     * @author Tim Roberts
     *
     */
    @SuppressWarnings("unused")
    private class IdValue {
        /** The id of the value */
        private final int id;

        /** The value for the id */
        private final String value;

        /**
         * Constructions ID/Value from the given parms (no validations are done)
         *
         * @param id the identifier
         * @param value the associated value
         */
        public IdValue(int id, String value) {
            this.id = id;
            this.value = value;
        }
    }

}