cz.cesnet.shongo.connector.device.CiscoMCUConnector.java Source code

Java tutorial

Introduction

Here is the source code for cz.cesnet.shongo.connector.device.CiscoMCUConnector.java

Source

package cz.cesnet.shongo.connector.device;

import cz.cesnet.shongo.AliasType;
import cz.cesnet.shongo.ExpirationMap;
import cz.cesnet.shongo.Technology;
import cz.cesnet.shongo.TodoImplementException;
import cz.cesnet.shongo.api.*;
import cz.cesnet.shongo.api.jade.CommandException;
import cz.cesnet.shongo.api.jade.CommandUnsupportedException;
import cz.cesnet.shongo.api.util.DeviceAddress;
import cz.cesnet.shongo.connector.common.AbstractMultipointConnector;
import cz.cesnet.shongo.connector.common.Command;
import cz.cesnet.shongo.connector.support.KeepAliveTransportFactory;
import cz.cesnet.shongo.connector.api.*;
import cz.cesnet.shongo.ssl.ConfiguredSSLContext;
import cz.cesnet.shongo.util.MathHelper;
import org.apache.commons.configuration.HierarchicalConfiguration;
import org.apache.commons.lang.StringUtils;
import org.apache.http.*;
import org.apache.http.client.HttpClient;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.params.HttpConnectionParams;
import org.apache.http.protocol.BasicHttpContext;
import org.apache.http.protocol.ExecutionContext;
import org.apache.http.protocol.HttpContext;
import org.apache.http.util.EntityUtils;
import org.apache.tika.detect.DefaultDetector;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.mime.MediaType;
import org.apache.xmlrpc.XmlRpcException;
import org.apache.xmlrpc.client.XmlRpcClient;
import org.apache.xmlrpc.client.XmlRpcClientConfigImpl;
import org.joda.time.DateTime;
import org.joda.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.MalformedURLException;
import java.net.SocketException;
import java.net.URL;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * A connector for Cisco TelePresence MCU.
 * <p/>
 * Uses HTTPS (only).
 * <p/>
 * Works using API 2.9. The following Cisco TelePresence products are supported, provided they are running MCU version
 * 4.3 or later:
 * - Cisco TelePresence MCU 4200 Series
 * - Cisco TelePresence MCU 4500 Series
 * - Cisco TelePresence MCU MSE 8420
 * - Cisco TelePresence MCU MSE 8510
 * <p/>
 *
 * @author Ondrej Bouda <ondrej.bouda@cesnet.cz>
 */
public class CiscoMCUConnector extends AbstractMultipointConnector {
    private static Logger logger = LoggerFactory.getLogger(CiscoMCUConnector.class);

    private static final Pattern E164_PATTERN = Pattern.compile("^\\+?\\d{9,14}$");

    /**
     * Options for the {@link CiscoMCUConnector}.
     */
    public static final String ROOM_NUMBER_EXTRACTION_FROM_H323_NUMBER = "room-number-extraction-from-h323-number";
    public static final String ROOM_NUMBER_EXTRACTION_FROM_SIP_URI = "room-number-extraction-from-sip-uri";

    /**
     * Maximum length of string which can be sent to the device.
     */
    private static final int STRING_MAX_LENGTH = 31;

    /**
     * Max gain of dB.
     */
    private static final int MAX_ABS_GAIN_DB = 20;

    /**
     * A safety limit for number of enumerate pages.
     * <p/>
     * When enumerating some objects, the MCU returns the results page by page. To protect the connector from infinite
     * loop when the device gives an incorrect results, there is a limit on the number of pages the connector processes.
     * If this limit is reached, an exception is thrown (i.e., no part of the result may be used), as such a behaviour
     * is considered erroneous.
     */
    private static final int ENUMERATE_PAGES_LIMIT = 1000;

    /**
     * The default port number to connect to.
     */
    public static final int DEFAULT_PORT = 443;

    /**
     * {@link XmlRpcClient} used for the XML-RPC API communication with the device.
     */
    private XmlRpcClient xmlRpcClient;

    /**
     * {@link XmlRpcClient} used for the Http communication with the device.
     */
    private HttpClient httpClient;

    /**
     * Detector for {@link MediaType}s.
     */
    private DefaultDetector detector = new DefaultDetector();

    /**
     * Authentication for the device.
     */
    private String authUsername;
    private String authPassword;

    /**
     * Patterns for options.
     */
    private Pattern roomNumberFromH323Number = null;
    private Pattern roomNumberFromSIPURI = null;

    /**
     * Addresses of participants which should be hidden.
     */
    private Set<String> hiddenParticipantAddresses = new HashSet<String>();

    /**
     * Cache of results of previous calls to commands supporting revision numbers.
     * Map of cache ID to previous results.
     */
    private final Map<String, ResultsCache> resultsCache = new HashMap<String, ResultsCache>();

    /**
     * Cache of snapshot URL for room participants ("roomId:roomParticipantId").
     */
    private final Map<String, String> roomParticipantSnapshotUrlCache = new HashMap<String, String>();

    /**
     * Cache of {@link MediaData} snapshots for room participants ("roomId:roomParticipantId").
     */
    private final ExpirationMap<String, MediaData> roomParticipantSnapshotCache = new ExpirationMap<String, MediaData>(
            Duration.standardSeconds(10));

    /**
     * @return URL for communication with the device via XML-RPC API
     */
    private URL getDeviceApiUrl() throws CommandException {
        // RPC2 is a fixed path given by Cisco, see the API docs
        String protocol = (deviceAddress.isSsl() ? "https" : "http");
        try {
            return new URL(protocol, deviceAddress.getHost(), deviceAddress.getPort(), "/RPC2");
        } catch (MalformedURLException exception) {
            throw new CommandException("Error constructing URL of the device.", exception);
        }
    }

    /**
     * @param file relative file
     * @return URL for communication with the device via Http
     */
    private URL getDeviceHttpUrl(String file) throws MalformedURLException {
        String protocol = (deviceAddress.isSsl() ? "https" : "http");
        return new URL(protocol, deviceAddress.getHost(), deviceAddress.getPort(), file);
    }

    // COMMON SERVICE

    /**
     * Connects to the MCU.
     * <p/>
     * Sets up the device URL where to send requests.
     * The communication protocol is stateless, though, so it just gets some info and does not hold the line.
     *
     * @param deviceAddress  device address to connect to
     * @param username username for authentication on the device
     * @param password password for authentication on the device
     * @throws cz.cesnet.shongo.api.jade.CommandException
     *
     */
    @Override
    public synchronized void connect(DeviceAddress deviceAddress, String username, String password)
            throws CommandException {
        if (deviceAddress.getPort() == DeviceAddress.DEFAULT_PORT) {
            deviceAddress.setPort(DEFAULT_PORT);
        }

        // Load options
        roomNumberFromH323Number = configuration.getOptionPattern(ROOM_NUMBER_EXTRACTION_FROM_H323_NUMBER);
        roomNumberFromSIPURI = configuration.getOptionPattern(ROOM_NUMBER_EXTRACTION_FROM_SIP_URI);

        hiddenParticipantAddresses.clear();
        for (Configuration participant : configuration.getOptionConfigurationList("participants.participant")) {
            boolean hide = participant.getBool("hide");
            if (hide) {
                String address = participant.getString("address");
                if (address == null || address.isEmpty()) {
                    throw new IllegalArgumentException("Address for participant must be filled.");
                }
                hiddenParticipantAddresses.add(address);
            }
        }

        try {
            // not standard basic auth - credentials are to be passed together with command parameters
            authUsername = username;
            authPassword = password;

            // Create XmlRpcClient for XML-RPC API communication
            XmlRpcClientConfigImpl config = new XmlRpcClientConfigImpl();
            config.setServerURL(getDeviceApiUrl());
            config.setConnectionTimeout(requestTimeout);
            config.setReplyTimeout(requestTimeout);
            xmlRpcClient = new XmlRpcClient();
            xmlRpcClient.setConfig(config);
            xmlRpcClient.setTransportFactory(new KeepAliveTransportFactory(xmlRpcClient));

            // Create HttpClient for Http communication
            httpClient = ConfiguredSSLContext.getInstance().createHttpClient(requestTimeout);

            // Get and check device info
            Map<String, Object> device = execApi(new Command("device.query"));
            try {
                String apiVersionString = (String) device.get("apiVersion");
                String[] apiVersionParts = apiVersionString.split("\\.");
                Double apiVersion = Double.valueOf(apiVersionParts[0])
                        + (Double.valueOf(apiVersionParts[1]) / 1000.0);
                if (apiVersion < 2.009) {
                    throw new CommandException(String.format(
                            "Device API %.1f too old. The connector only works with API 2.9 or higher.",
                            apiVersion));
                }
            } catch (Exception exception) {
                throw new CommandException("Cannot determine the device API version.", exception);
            }
            setDeviceName((String) device.get("model"));
            setDeviceSerialNumber((String) device.get("serial"));
            setDeviceSoftwareVersion(device.get("softwareVersion") + " (" + "API: " + device.get("apiVersion")
                    + ", " + "build: " + device.get("buildVersion") + ")");
        } catch (CommandException exception) {
            throw new CommandException("Error setting up connection to the device.", exception);
        }
    }

    @Override
    public ConnectionState getConnectionState() {
        try {
            synchronized (this) {
                XmlRpcClientConfigImpl configuration = ((XmlRpcClientConfigImpl) xmlRpcClient.getConfig());
                configuration.setReplyTimeout(CONNECTION_STATE_TIMEOUT);
                execApi("device.query", null);
                configuration.setReplyTimeout(requestTimeout);
            }
            return ConnectionState.CONNECTED;
        } catch (Exception exception) {
            logger.warn("Not connected", exception);
            return ConnectionState.DISCONNECTED;
        }
    }

    @Override
    public synchronized void disconnect() throws CommandException {
        // TODO: consider publishing feedback events from the MCU
        // no real operation - the communication protocol is stateless
        xmlRpcClient = null; // just for sure the attributes are not used anymore
    }

    //</editor-fold>

    //<editor-fold desc="ROOM SERVICE">

    @Override
    public Collection<RoomSummary> listRooms() throws CommandException {
        Command cmd = new Command("conference.enumerate");
        cmd.setParameter("moreThanFour", Boolean.TRUE);
        cmd.setParameter("enumerateFilter", "!completed");

        Collection<RoomSummary> rooms = new ArrayList<RoomSummary>();
        List<Map<String, Object>> conferences = execApiEnumerate(cmd, "conferences");
        for (Map<String, Object> conference : conferences) {
            RoomSummary info = extractRoomSummary(conference);
            rooms.add(info);
        }

        return rooms;
    }

    @Override
    public Room getRoom(String roomId) throws CommandException {
        Map<String, Object> conferenceStatus;
        try {
            Command cmd = new Command("conference.status");
            cmd.setParameter("conferenceName", truncateString(roomId));
            conferenceStatus = execApi(cmd);
        } catch (CommandException exception) {
            if (exception.getMessage().equals("no such conference or auto attendant")) {
                return null;
            } else {
                throw exception;
            }
        }

        Room room = new Room();
        room.setId((String) conferenceStatus.get("conferenceName"));
        room.addAlias(AliasType.ROOM_NAME, (String) conferenceStatus.get("conferenceName"));
        if (conferenceStatus.containsKey("maximumVideoPorts")) {
            room.setLicenseCount((Integer) conferenceStatus.get("maximumVideoPorts"));
        }
        room.addTechnology(Technology.H323);

        if (conferenceStatus.containsKey("description") && !conferenceStatus.get("description").equals("")) {
            room.setDescription((String) conferenceStatus.get("description"));
        }

        // aliases
        if (conferenceStatus.containsKey("numericId") && !conferenceStatus.get("numericId").equals("")) {
            Alias numAlias = new Alias(AliasType.H323_E164, (String) conferenceStatus.get("numericId"));
            room.addAlias(numAlias);
        }

        // layout
        if (conferenceStatus.containsKey("customLayout")) {
            room.setLayout(getRoomLayoutByLayoutIndex((Integer) conferenceStatus.get("customLayout")));
        }

        // options
        H323RoomSetting h323RoomSetting = new H323RoomSetting();
        if (!conferenceStatus.get("pin").equals("")) {
            h323RoomSetting.setPin((String) conferenceStatus.get("pin"));
        }
        h323RoomSetting.setListedPublicly(!(Boolean) conferenceStatus.get("private"));
        h323RoomSetting.setAllowContent((Boolean) conferenceStatus.get("contentContribution"));
        h323RoomSetting.setJoinMicrophoneDisabled((Boolean) conferenceStatus.get("joinAudioMuted"));
        h323RoomSetting.setJoinVideoDisabled((Boolean) conferenceStatus.get("joinVideoMuted"));
        h323RoomSetting.setRegisterWithGatekeeper((Boolean) conferenceStatus.get("registerWithGatekeeper"));
        h323RoomSetting.setRegisterWithRegistrar((Boolean) conferenceStatus.get("registerWithSIPRegistrar"));
        h323RoomSetting.setStartLocked((Boolean) conferenceStatus.get("startLocked"));
        h323RoomSetting.setConferenceMeEnabled((Boolean) conferenceStatus.get("conferenceMeEnabled"));
        room.addRoomSetting(h323RoomSetting);

        return room;
    }

    @Override
    public String createRoom(Room room) throws CommandException {
        Command cmd = new Command("conference.create");

        cmd.setParameter("customLayoutEnabled", Boolean.TRUE);
        cmd.setParameter("newParticipantsCustomLayout", Boolean.TRUE);

        cmd.setParameter("enforceMaximumAudioPorts", Boolean.TRUE);
        cmd.setParameter("maximumAudioPorts", 0); // audio-only participants are forced to use video slots
        cmd.setParameter("enforceMaximumVideoPorts", Boolean.TRUE);

        // defaults (may be overridden by specified room options
        cmd.setParameter("private", Boolean.TRUE);
        cmd.setParameter("contentContribution", Boolean.TRUE);
        cmd.setParameter("contentTransmitResolutions", "allowAll");
        cmd.setParameter("joinAudioMuted", Boolean.FALSE);
        cmd.setParameter("joinVideoMuted", Boolean.FALSE);
        cmd.setParameter("startLocked", Boolean.FALSE);
        cmd.setParameter("conferenceMeEnabled", Boolean.FALSE);

        // Set default layout to SPEAKER_CORNER
        if (room.getLayout() == null) {
            room.setLayout(RoomLayout.SPEAKER_CORNER);
        }
        setConferenceParametersByRoom(cmd, room);

        // Room name must be filled
        if (cmd.getParameterValue("conferenceName") == null) {
            throw new RuntimeException("Room name must be filled for the new room.");
        }

        execApi(cmd);

        return (String) cmd.getParameterValue("conferenceName");
    }

    private void setConferenceParametersByRoom(Command cmd, Room room) throws CommandException {
        // Set the room forever
        cmd.setParameter("durationSeconds", 0);

        // Set the license count
        cmd.setParameter("maximumVideoPorts", (room.getLicenseCount() > 0 ? room.getLicenseCount() : 0));

        // Default layout
        RoomLayout roomLayout = room.getLayout();
        if (roomLayout != null) {
            Integer layoutIndex = getLayoutIndexByRoomLayout(roomLayout);
            if (layoutIndex != null) {
                cmd.setParameter("customLayout", layoutIndex);
            }
        }

        // Set the description
        if (room.getDescription() != null) {
            cmd.setParameter("description", truncateString(room.getDescription()));
        }

        // Create/Update aliases
        if (room.getAliases() != null) {
            for (Alias alias : room.getAliases()) {
                // Derive number/name of the room
                String roomNumber = null;
                String roomName = null;
                Matcher m;

                switch (alias.getType()) {
                case ROOM_NAME:
                    roomName = alias.getValue();
                    break;
                case H323_E164:
                    if (roomNumberFromH323Number == null) {
                        throw new CommandException(
                                String.format("Cannot set H.323 E164 number - missing connector device option '%s'",
                                        ROOM_NUMBER_EXTRACTION_FROM_H323_NUMBER));
                    }
                    m = roomNumberFromH323Number.matcher(alias.getValue());
                    if (!m.find()) {
                        throw new CommandException("Invalid E164 number: " + alias.getValue());
                    }
                    roomNumber = m.group(1);
                    break;
                case SIP_URI:
                    if (roomNumberFromSIPURI == null) {
                        throw new CommandException(
                                String.format("Cannot set SIP URI to room - missing connector device option '%s'",
                                        ROOM_NUMBER_EXTRACTION_FROM_SIP_URI));
                    }
                    m = roomNumberFromSIPURI.matcher(alias.getValue());
                    if (m.find()) {
                        // SIP URI contains number
                        roomNumber = m.group(1);
                    } else {
                        // SIP URI contains name
                        String value = alias.getValue();
                        int atSign = value.indexOf('@');
                        assert atSign > 0;
                        roomName = value.substring(0, atSign);
                    }
                    break;
                case H323_URI:
                case H323_IP:
                case SIP_IP:
                    // TODO: Check the alias value
                    break;
                default:
                    throw new CommandException("Unrecognized alias: " + alias.toString());
                }

                if (roomNumber != null) {
                    // Check we are not already assigning a different number to the room
                    final Object oldRoomNumber = cmd.getParameterValue("numericId");
                    if (oldRoomNumber != null && !oldRoomNumber.equals("") && !oldRoomNumber.equals(roomNumber)) {
                        // multiple number aliases
                        throw new CommandException(String.format(
                                "The connector supports only one number for a room, requested another: %s", alias));
                    }
                    cmd.setParameter("numericId", truncateString(roomNumber));
                }

                if (roomName != null) {
                    // Check that more aliases do not request different room name
                    final Object oldRoomName = cmd.getParameterValue("conferenceName");
                    if (oldRoomName != null && !oldRoomName.equals("") && !oldRoomName.equals(roomName)) {
                        throw new CommandException(String
                                .format("The connector supports only one room name, requested another: %s", alias));
                    }
                    cmd.setParameter("conferenceName", truncateString(roomName));
                }
            }
        }

        H323RoomSetting h323RoomSetting = room.getRoomSetting(H323RoomSetting.class);
        if (h323RoomSetting != null) {
            if (h323RoomSetting.getPin() != null) {
                cmd.setParameter("pin", h323RoomSetting.getPin());
            }
            if (h323RoomSetting.getListedPublicly() != null) {
                cmd.setParameter("private", !h323RoomSetting.getListedPublicly());
            }
            if (h323RoomSetting.getAllowContent() != null) {
                cmd.setParameter("contentContribution", h323RoomSetting.getAllowContent());
            }
            if (h323RoomSetting.getJoinMicrophoneDisabled() != null) {
                cmd.setParameter("joinAudioMuted", h323RoomSetting.getJoinMicrophoneDisabled());
            }
            if (h323RoomSetting.getJoinVideoDisabled() != null) {
                cmd.setParameter("joinVideoMuted", h323RoomSetting.getJoinVideoDisabled());
            }
            if (h323RoomSetting.getRegisterWithGatekeeper() != null) {
                cmd.setParameter("registerWithGatekeeper", h323RoomSetting.getRegisterWithGatekeeper());
            }
            if (h323RoomSetting.getRegisterWithRegistrar() != null) {
                cmd.setParameter("registerWithSIPRegistrar", h323RoomSetting.getRegisterWithRegistrar());
            }
            if (h323RoomSetting.getStartLocked() != null) {
                cmd.setParameter("startLocked", h323RoomSetting.getStartLocked());
            }
            if (h323RoomSetting.getConferenceMeEnabled() != null) {
                cmd.setParameter("conferenceMeEnabled", h323RoomSetting.getConferenceMeEnabled());
            }
            if (h323RoomSetting.getAllowGuests() != null) {
                throw new CommandException(
                        "Room Setting " + H323RoomSetting.ALLOW_GUESTS + "is not implemented yet.");
            }
        }
    }

    @Override
    protected boolean isRecreateNeeded(Room oldRoom, Room newRoom) throws CommandException {
        Alias oldRoomName = oldRoom.getAlias(AliasType.ROOM_NAME);
        Alias newRoomName = newRoom.getAlias(AliasType.ROOM_NAME);
        if (oldRoomName == null) {
            throw new CommandException("Room " + oldRoom.getId() + " doesn't have room name.");
        }
        if (newRoomName == null) {
            throw new CommandException("Room name must be present.");
        }
        return !newRoomName.equals(oldRoomName);
    }

    /**
     *
     * Disconnect participants in room to except number specified by {@code licenseCount}.
     *
     * @param licenseCount number of participants which can be kept in the room (0 means disconnect all participants,
     *                     1 all participants except one, etc)
     */
    private void disconnectRoomParticipants(String roomId, int licenseCount) throws CommandException {
        int participantCount = 0;
        for (RoomParticipant roomParticipant : getRoomParticipants(roomId, true)) {
            // Disconnect participant only when he excess specified license count
            if (participantCount < licenseCount) {
                logger.debug("Keeping participant {} connected to room {}...",
                        new Object[] { roomParticipant, roomId });
                participantCount++;
                continue;
            }
            try {
                logger.warn("Disconnecting participant {} in room {} to meet license count {}...",
                        new Object[] { roomParticipant, roomId, licenseCount });
                disconnectRoomParticipant(roomParticipant.getRoomId(), roomParticipant.getId());
            } catch (CommandException exception) {
                throw new CommandException("Cannot disconnect participant" + roomParticipant + ".", exception);
            }
        }
    }

    @Override
    protected void onModifyRoom(Room room) throws CommandException {
        disconnectRoomParticipants(room.getId(), room.getLicenseCount());

        Command cmd = new Command("conference.modify");
        cmd.setParameter("conferenceName", truncateString(room.getId()));
        setConferenceParametersByRoom(cmd, room);
        execApi(cmd);
    }

    @Override
    public void deleteRoom(String roomId) throws CommandException {
        Command cmd = new Command("conference.destroy");
        cmd.setParameter("conferenceName", truncateString(roomId));
        execApi(cmd);
    }

    @Override
    public String exportRoomSettings(String roomId) throws CommandException, CommandUnsupportedException {
        throw new CommandUnsupportedException(); // TODO
    }

    @Override
    public void importRoomSettings(String roomId, String settings)
            throws CommandException, CommandUnsupportedException {
        throw new CommandUnsupportedException(); // TODO
    }

    //</editor-fold>

    //<editor-fold desc="ROOM CONTENT SERVICE">

    @Override
    public void removeRoomContentFile(String roomId, String name)
            throws CommandException, CommandUnsupportedException {
        throw new CommandUnsupportedException(); // TODO
    }

    @Override
    public MediaData getRoomContent(String roomId) throws CommandException, CommandUnsupportedException {
        throw new CommandUnsupportedException(); // TODO
    }

    @Override
    public void clearRoomContent(String roomId) throws CommandException, CommandUnsupportedException {
        throw new CommandUnsupportedException(); // TODO
    }

    @Override
    public void addRoomContent(String roomId, String name, MediaData data)
            throws CommandException, CommandUnsupportedException {
        throw new CommandUnsupportedException(); // TODO
    }

    //</editor-fold>

    //<editor-fold desc="USER SERVICE">

    @Override
    public Collection<RoomParticipant> listRoomParticipants(String roomId) throws CommandException {
        return getRoomParticipants(roomId, false);
    }

    @Override
    public RoomParticipant getRoomParticipant(String roomId, String roomParticipantId) throws CommandException {
        Command cmd = new Command("participant.status");
        identifyParticipant(cmd, roomId, roomParticipantId);
        cmd.setParameter("operationScope", new String[] { "currentState" });

        Map<String, Object> result = execApi(cmd);

        return extractRoomParticipant(result);
    }

    @Override
    public Map<String, MediaData> getRoomParticipantSnapshots(String roomId, Set<String> roomParticipantIds)
            throws CommandException {
        Map<String, MediaData> participantSnapshots = new HashMap<String, MediaData>();
        for (String roomParticipantId : roomParticipantIds) {
            String cacheId = roomId + ":" + roomParticipantId;
            synchronized (roomParticipantSnapshotCache) {
                MediaData roomParticipantSnapshot = roomParticipantSnapshotCache.get(cacheId);
                if (roomParticipantSnapshot == null) {
                    roomParticipantSnapshot = getRoomParticipantSnapshot(roomId, roomParticipantId);
                    roomParticipantSnapshotCache.put(cacheId, roomParticipantSnapshot);
                }
                participantSnapshots.put(roomParticipantId, roomParticipantSnapshot);
            }
        }
        return participantSnapshots;
    }

    @Override
    public void modifyRoomParticipant(RoomParticipant roomParticipant) throws CommandException {
        String roomId = roomParticipant.getRoomId();
        if (roomId == null) {
            throw new IllegalArgumentException("RoomId must be not null.");
        }
        String roomParticipantId = roomParticipant.getId();
        if (roomParticipantId == null) {
            throw new IllegalArgumentException("RoomParticipantId must be not null.");
        }

        Command cmd = new Command("participant.modify");
        identifyParticipant(cmd, roomId, roomParticipantId);

        // NOTE: oh yes, Cisco MCU wants "activeState" for modify while for status, it gets "currentState"...
        cmd.setParameter("operationScope", "activeState");

        // @see extractRoomParticipant for more info we we don't return participant layout
        //if (roomParticipant.getLayout() != null) {
        //    Integer layoutIndex = getLayoutIndexByRoomLayout(roomParticipant.getLayout());
        //    if (layoutIndex != null) {
        //        cmd.setParameter("cpLayout", "layout" + layoutIndex);
        //    }
        //}

        // Set parameters
        if (roomParticipant.getDisplayName() != null) {
            cmd.setParameter("displayNameOverrideValue", truncateString(roomParticipant.getDisplayName()));
            cmd.setParameter("displayNameOverrideStatus", Boolean.TRUE); // for the value to take effect
        }
        if (roomParticipant.getMicrophoneEnabled() != null) {
            cmd.setParameter("audioRxMuted", !roomParticipant.getMicrophoneEnabled());
        }
        if (roomParticipant.getVideoEnabled() != null) {
            cmd.setParameter("videoRxMuted", !roomParticipant.getVideoEnabled());
        }
        Integer microphoneLevel = roomParticipant.getMicrophoneLevel();
        if (microphoneLevel != null && !microphoneLevel.equals(RoomParticipant.DEFAULT_MICROPHONE_LEVEL)) {
            double gainDb = MathHelper.getDbFromPercent(((double) roomParticipant.getMicrophoneLevel() - 5.0) / 5.0,
                    MAX_ABS_GAIN_DB);
            cmd.setParameter("audioRxGainMillidB", (int) (gainDb * 1000.0));
            cmd.setParameter("audioRxGainMode", "fixed");
        } else {
            cmd.setParameter("audioRxGainMode", "default");
        }

        // Content
        // NOTE: it seems it is not possible to enable content using current API (2.9)
        //throw new CommandUnsupportedException();

        execApi(cmd);
    }

    @Override
    public void modifyRoomParticipants(RoomParticipant roomParticipantConfiguration)
            throws CommandException, CommandUnsupportedException {
        for (RoomParticipant roomParticipant : getRoomParticipants(roomParticipantConfiguration.getRoomId(),
                false)) {
            roomParticipantConfiguration.setId(roomParticipant.getId());
            if (!roomParticipantConfiguration.isSame(roomParticipant)) {
                modifyRoomParticipant(roomParticipantConfiguration);
            }
        }
    }

    @Override
    public String dialRoomParticipant(String roomId, Alias alias) throws CommandException {
        // FIXME: refine just as the createRoom() method - get just a RoomParticipant object and set parameters according to it

        // NOTE: adding participants as ad_hoc - the MCU autogenerates their IDs (but they are just IDs, not names),
        //       thus, commented out the following generation of participant names
        //String roomParticipantId = generateRoomParticipantId(roomId); // FIXME: treat potential race conditions; and it is slow...

        Command cmd = new Command("participant.add");
        cmd.setParameter("conferenceName", truncateString(roomId));
        //cmd.setParameter("participantName", truncateString(roomParticipantId));
        cmd.setParameter("address", truncateString(alias.getValue()));
        cmd.setParameter("participantType", "ad_hoc");
        cmd.setParameter("addResponse", Boolean.TRUE);

        Map<String, Object> result = execApi(cmd);

        @SuppressWarnings("unchecked")
        Map<String, Object> participant = (Map<String, Object>) result.get("participant");
        if (participant == null) {
            return null;
        } else {
            return String.valueOf(participant.get("participantName"));
        }
    }

    @Override
    public void disconnectRoomParticipant(String roomId, String roomParticipantId) throws CommandException {
        Command cmd = new Command("participant.remove");
        identifyParticipant(cmd, roomId, roomParticipantId);

        execApi(cmd);
    }

    //</editor-fold>

    //<editor-fold desc="MONITORING SERVICE">

    @Override
    public DeviceLoadInfo getDeviceLoadInfo() throws CommandException {
        Map<String, Object> health = execApi(new Command("device.health.query"));
        Map<String, Object> status = execApi(new Command("device.query"));

        DeviceLoadInfo info = new DeviceLoadInfo();
        info.setCpuLoad(((Integer) health.get("cpuLoad")).doubleValue());
        if (status.containsKey("uptime")) {
            info.setUptime((Integer) status.get("uptime")); // NOTE: 'uptime' not documented, but it is there
        }

        // NOTE: memory and disk usage not accessible via API

        return info;
    }

    @Override
    public UsageStats getUsageStats() throws CommandException, CommandUnsupportedException {
        throw new CommandUnsupportedException(); // TODO
    }

    //</editor-fold>

    /**
     * Perform http request for given {@code file}
     *
     * @param file
     * @return content as {@link MediaData}
     * @throws CommandException
     */
    private synchronized MediaData execHttp(String file) throws CommandException {
        try {
            URL requestUrl = getDeviceHttpUrl(file);
            HttpGet request = new HttpGet(requestUrl.toURI());
            HttpContext context = new BasicHttpContext();
            HttpResponse response = httpClient.execute(request, context);
            HttpRequest responseRequest = (HttpRequest) context.getAttribute(ExecutionContext.HTTP_REQUEST);
            StatusLine responseStatusLine = response.getStatusLine();
            if (responseStatusLine.getStatusCode() == HttpStatus.SC_OK) {
                if (responseRequest.getRequestLine().getUri().startsWith("/login.html")) {
                    // Perform login
                    loginHttp();
                    // Perform the request again
                    response = httpClient.execute(request, context);
                }
                HttpEntity responseEntity = response.getEntity();
                if (responseEntity != null) {
                    byte[] mediaContent = EntityUtils.toByteArray(responseEntity);
                    MediaType mediaType = detector.detect(TikaInputStream.get(mediaContent), new Metadata());
                    return new MediaData(mediaType, mediaContent);
                }
            }
            throw new RuntimeException(response.getStatusLine().toString());
        } catch (CommandException exception) {
            throw exception;
        } catch (Exception exception) {
            throw new CommandException("Http request " + file + " failed.", exception);
        }
    }

    /**
     * Try to login for {@link #httpClient}
     *
     * @throws CommandException when login fails
     */
    private void loginHttp() throws CommandException {
        try {
            HttpPost request = new HttpPost(getDeviceHttpUrl("/login_change.html").toURI());
            List<NameValuePair> parameters = new ArrayList<NameValuePair>(2);
            parameters.add(new BasicNameValuePair("user_name", authUsername));
            parameters.add(new BasicNameValuePair("password", authPassword));
            parameters.add(new BasicNameValuePair("ok", "OK"));
            request.setEntity(new UrlEncodedFormEntity(parameters, "UTF-8"));
            HttpContext context = new BasicHttpContext();
            HttpResponse response = httpClient.execute(request, context);
            HttpRequest responseRequest = (HttpRequest) context.getAttribute(ExecutionContext.HTTP_REQUEST);
            StatusLine responseStatusLine = response.getStatusLine();
            if (responseStatusLine.getStatusCode() != HttpStatus.SC_OK) {
                throw new RuntimeException("Wrong status " + responseStatusLine);
            }
            String responseRequestUrl = responseRequest.getRequestLine().getUri();
            if (responseRequestUrl.startsWith("/index.html") || responseRequestUrl.equals("/")) {
                logger.info("Http login successful.");
            } else {
                throw new RuntimeException("Wrong response url " + responseRequestUrl);
            }
        } catch (Exception exception) {
            throw new CommandException("Http login failed", exception);
        }
    }

    /**
     * Sends a command to the device. Blocks until response to the command is complete.
     *
     * @param command a command to the device; note that some parameters may be added to the command
     * @return output of the command
     */
    private Map<String, Object> execApi(Command command) throws CommandException {
        int retryCount = 5;
        while (retryCount > 0) {
            try {
                return execApi(command.getCommand(), command.getParameters());
            } catch (XmlRpcException exception) {
                if (isExecApiRetryPossible(exception)) {
                    retryCount--;
                    logger.warn("{}: Trying again...", exception.getMessage());
                    continue;
                } else {
                    throw new CommandException(exception.getMessage(), exception.getCause());
                }
            }
        }
        throw new CommandException(String.format("Command %s failed.", command));
    }

    /**
     * Sends a command to the device. Blocks until response to the command is complete.
     *
     * @param command
     * @param params
     * @return output of the command
     * @throws XmlRpcException
     */
    private synchronized Map<String, Object> execApi(String command, Map<String, Object> params)
            throws XmlRpcException {
        logger.debug(String.format("Issuing command '%s' on %s", command, deviceAddress));
        HashMap<String, Object> content = new HashMap<String, Object>();
        if (params != null) {
            content.putAll(params);
        }
        content.put("authenticationUser", authUsername);
        content.put("authenticationPassword", authPassword);
        @SuppressWarnings("unchecked")
        Map<String, Object> result = (Map<String, Object>) xmlRpcClient.execute(command, new Object[] { content });
        return result;
    }

    /**
     * @param exception
     * @return true whether given {@code exception} allows to retry the API request, false otherwise
     */
    protected boolean isExecApiRetryPossible(XmlRpcException exception) {
        Throwable cause = exception.getCause();
        return (cause instanceof SocketException && cause.getMessage().equals("Connection reset"));
    }

    /**
     * Executes a command enumerating some objects.
     * <p/>
     * When possible (currently for commands conference.enumerate and participant.enumerate), caches the results and
     * asks just for the difference since previous call of the same command.
     * The caching is intentionally disabled for the autoAttendants.enumerate command, as the revisioning mechanism
     * seems to be broken on the device (it reports dead items even with the listAll parameter set to true), and either
     * way it generates short lists.
     *
     * @param command   command for enumerating the objects; note that some parameters may be added to the command
     * @param enumField the field within result containing the list of enumerated objects
     * @return list of objects from the enumField, each as a map from field names to values;
     *         the list is unmodifiable (so that it may be reused by the execApiEnumerate() method)
     * @throws CommandException
     */
    private synchronized List<Map<String, Object>> execApiEnumerate(Command command, String enumField)
            throws CommandException {
        List<Map<String, Object>> results = new ArrayList<Map<String, Object>>();

        // use revision numbers to get just the difference from the previous call of this command
        Integer lastRevision = prepareCaching(command);
        Integer currentRevision = null;

        for (int enumPage = 0;; enumPage++) {
            // safety pages number check - to prevent infinite loop if the device does not work correctly
            if (enumPage >= ENUMERATE_PAGES_LIMIT) {
                String message = String.format(
                        "Enumerate pages safety limit reached - the device gave more than %d result pages!",
                        ENUMERATE_PAGES_LIMIT);
                throw new CommandException(message);
            }

            // ask for data
            Map<String, Object> result = execApi(command);
            // get the revision number of the first page - for using cache
            if (enumPage == 0) {
                currentRevision = (Integer) result.get("currentRevision"); // might not exist in the result and be null
            }

            // process data
            if (!result.containsKey(enumField)) {
                break; // no data at all
            }
            Object[] data = (Object[]) result.get(enumField);
            for (Object obj : data) {
                results.add((Map<String, Object>) obj);
            }

            // ask for more results, or break if that was all
            if (result.containsKey("enumerateID")) {
                command.setParameter("enumerateID", result.get("enumerateID"));
            } else {
                break; // that's all, folks
            }
        }

        if (currentRevision != null) {
            populateResultsFromCache(results, currentRevision, lastRevision, command, enumField);
        }

        return Collections.unmodifiableList(results);
    }

    /**
     * For string parameters, MCU accepts only strings of limited length.
     * <p/>
     * There are just a few exceptions to the limit. For the rest, this method ensures truncation with logging strings
     * that are longer.
     * <p/>
     * Constant <code>STRING_MAX_LENGTH</code> is used as the limit.
     *
     * @param str string to be (potentially) truncated
     * @return <code>str</code> truncated to the maximum length supported by the device
     */
    private static String truncateString(String str) {
        if (str == null) {
            return "";
        }
        if (str.length() > STRING_MAX_LENGTH) {
            logger.warn("Too long string: '" + str + "', the device only supports " + STRING_MAX_LENGTH
                    + "-character strings");
            str = str.substring(0, STRING_MAX_LENGTH);
        }
        return str;
    }

    //<editor-fold desc="RESULTS CACHING">

    /**
     * Prepares caching of result of the supplied command.
     *
     * @param command command to be issued; may be modified (some parameters regarding caching may be added)
     * @return the last revision when the same command was issued
     */
    private Integer prepareCaching(Command command) {
        Integer lastRevision = getCachedRevision(command);
        if (lastRevision != null) {
            command.setParameter("lastRevision", lastRevision);
            command.setParameter("listAll", Boolean.TRUE);
        }
        return lastRevision;
    }

    /**
     * Populates the results list - puts the original objects instead of item stubs.
     * <p/>
     * If there was a previous call to the same command, the changed items are just stubs in the new result set. To use
     * the results, this method populates all the stubs and puts the objects from the previous call in their place.
     *
     * @param results         list of results, some of which may be stubs; gets modified so that it contains no stubs
     * @param currentRevision the revision of this results
     * @param lastRevision    the revision of the previous call of the same command
     * @param command         the command called to get the supplied results
     * @param enumField       the field name from which the supplied results where taken within the command result
     */
    private void populateResultsFromCache(List<Map<String, Object>> results, Integer currentRevision,
            Integer lastRevision, Command command, String enumField) throws CommandException {
        // we got just the difference since lastRevision (or full set if this is the first issue of the command)
        final String cacheId = getCommandCacheId(command);

        if (lastRevision != null) {
            // fill the values that have not changed since lastRevision
            ListIterator<Map<String, Object>> iterator = results.listIterator();
            while (iterator.hasNext()) {
                Map<String, Object> item = iterator.next();

                if (isItemDead(item)) {
                    // from the MCU API: "The device will also never return a dead record if listAll is set to true."
                    // unfortunately, the buggy MCU still reports some items as dead even though listAll = true, so we
                    //   must remove them by ourselves (according to the API, a dead item should not have been ever
                    //   listed when listAll = true)
                    iterator.remove();
                } else if (!hasItemChanged(item)) {
                    ResultsCache cache = resultsCache.get(cacheId);
                    Map<String, Object> it = cache.getItem(item);
                    if (it == null) {
                        throw new CommandException(
                                "Item reported as not changed by the device, but was not found in the cache: "
                                        + item);
                    }
                    iterator.set(it);
                }
            }
        }

        // store the results and the revision number for the next time
        ResultsCache rc = resultsCache.get(cacheId);
        if (rc == null) {
            rc = new ResultsCache();
            resultsCache.put(cacheId, rc);
        }
        rc.store(currentRevision, results);
    }

    /**
     * Tells whether an item from a result of an enumeration command has been removed since last time the command was
     * issued.
     *
     * @param item an item from the resulting list
     * @return <code>true</code> if the item is marked as removed, <code>false</code> if not
     */
    private static boolean isItemDead(Map<String, Object> item) {
        // try directly the item "dead" attribute
        if (Boolean.TRUE.equals(item.get("dead"))) {
            return true;
        }

        // for some enumeration items (namely "participants"), the "dead" attribute might? (who knows, it shouldn't
        //   have been there for any result, since listAll=true) be listed in "currentState"
        @SuppressWarnings("unchecked")
        Map<String, Object> currentState = (Map<String, Object>) item.get("currentState");
        if (currentState != null && Boolean.TRUE.equals(currentState.get("dead"))) {
            return true;
        }

        return false; // not reported as dead
    }

    /**
     * Tells whether an item from a result of an enumeration command changed since last time the command was issued.
     *
     * @param item an item from the resulting list
     * @return <code>false</code> if the item is marked as not changed,
     *         <code>true</code> if the item is not marked as not changed
     */
    private static boolean hasItemChanged(Map<String, Object> item) {
        // try directly the item "changed" attribute
        if (Boolean.FALSE.equals(item.get("changed"))) {
            return false;
        }

        // for some enumeration items (namely "participants"), the "changed" attribute might be listed in "currentState"
        @SuppressWarnings("unchecked")
        Map<String, Object> currentState = (Map<String, Object>) item.get("currentState");
        if (currentState != null && Boolean.FALSE.equals(currentState.get("changed"))) {
            return false;
        }

        return true; // not reported as not changed
    }

    /**
     * Returns the revision number of the previous call of the given command.
     * <p/>
     * The purpose of this method is to enable caching of previous calls and asking for just the difference since then.
     * <p/>
     * All the parameters of the command are considered, except enumerateID, lastRevision, and listAll.
     * <p/>
     * Note that the return value must be boxed, because the MCU API does not say anything about the revision numbers
     * issued by the device. So it may have any value, thus, we must recognize the special case by the null value.
     *
     * @param command a command which will be performed
     * @return revision number of the previous call of the given command,
     *         or null if the command has not been issued yet or does not support revision numbers
     */
    private Integer getCachedRevision(Command command) {
        if (command.getCommand().equals("autoAttendant.enumerate")) {
            return null; // disabled for the autoAttendant.enumerate command - it is broken on the device
        }
        String cacheId = getCommandCacheId(command);
        ResultsCache rc = resultsCache.get(cacheId);
        return (rc == null ? null : rc.getRevision());
    }

    private String getCommandCacheId(Command command) {
        final String[] ignoredParams = new String[] { "enumerateID", "lastRevision", "listAll",
                "authenticationUser", "authenticationPassword" };

        StringBuilder sb = new StringBuilder(command.getCommand());
        ParamsLoop: for (Map.Entry<String, Object> entry : command.getParameters().entrySet()) {
            for (String ignoredParam : ignoredParams) {
                if (entry.getKey().equals(ignoredParam)) {
                    continue ParamsLoop; // the parameter is ignored
                }
            }
            sb.append(";");
            sb.append(entry.getKey());
            sb.append("=");
            sb.append(entry.getValue());
        }

        return sb.toString();
    }

    private static RoomSummary extractRoomSummary(Map<String, Object> conference) {
        RoomSummary roomSummary = new RoomSummary();
        roomSummary.setId((String) conference.get("conferenceName"));
        roomSummary.setName((String) conference.get("conferenceName"));
        roomSummary.setDescription((String) conference.get("description"));
        roomSummary.setAlias((String) conference.get("numericId"));
        String timeField = (conference.containsKey("startTime") ? "startTime" : "activeStartTime");
        roomSummary.setStartDateTime(new DateTime(conference.get(timeField)));
        return roomSummary;
    }

    private Collection<RoomParticipant> getRoomParticipants(String roomId, boolean withHidden)
            throws CommandException {
        Command cmd = new Command("participant.enumerate");
        cmd.setParameter("operationScope", new String[] { "currentState" });
        cmd.setParameter("enumerateFilter", "connected");
        List<Map<String, Object>> participants = execApiEnumerate(cmd, "participants");

        List<RoomParticipant> hiddenResult = new ArrayList<RoomParticipant>();
        List<RoomParticipant> result = new ArrayList<RoomParticipant>();
        for (Map<String, Object> participant : participants) {
            if (participant == null) {
                continue;
            }
            if (!roomId.equals(participant.get("conferenceName"))) {
                // not from this room
                continue;
            }
            Map<String, Object> participantState = (Map<String, Object>) participant.get("currentState");
            String participantAddress = (String) participantState.get("address");
            if (participantAddress != null && hiddenParticipantAddresses.contains(participantAddress)) {
                hiddenResult.add(extractRoomParticipant(participant));
            } else {
                result.add(extractRoomParticipant(participant));
            }
        }

        // Append hidden participants to the end
        if (withHidden) {
            result.addAll(hiddenResult);
        }

        return result;
    }

    /**
     * Extracts a {@link RoomParticipant} out of participant.enumerate or participant.status result.
     *
     * @param participant participant structure, as defined in the MCU API, command participant.status
     * @return {@link RoomParticipant} extracted from the participant structure
     */
    private RoomParticipant extractRoomParticipant(Map<String, Object> participant) {
        RoomParticipant roomParticipant = new RoomParticipant();
        RoomParticipantIdentifier identifier = new RoomParticipantIdentifier(participant);
        String protocol = identifier.getParticipantProtocol();

        String roomId = (String) participant.get("conferenceName");
        roomParticipant.setId(identifier.toString());
        roomParticipant.setRoomId(roomId);

        @SuppressWarnings("unchecked")
        Map<String, Object> state = (Map<String, Object>) participant.get("currentState");

        String address = (String) state.get("address");
        AliasType aliasType;
        if (protocol.equals("sip")) {
            aliasType = AliasType.SIP_URI;
        } else {
            if (E164_PATTERN.matcher(address).matches()) {
                aliasType = AliasType.H323_E164;
            } else {
                aliasType = AliasType.H323_URI;
            }
        }
        roomParticipant.setAlias(new Alias(aliasType, address));
        roomParticipant.setDisplayName((String) state.get("displayName"));

        roomParticipant.setMicrophoneEnabled(!(Boolean) state.get("audioRxMuted"));
        roomParticipant.setVideoEnabled(!(Boolean) state.get("videoRxMuted"));
        if (state.get("audioRxGainMode").equals("fixed")) {
            double gainDb = (double) ((Integer) state.get("audioRxGainMillidB")) / 1000.0;
            roomParticipant
                    .setMicrophoneLevel((int) (MathHelper.getPercentFromDb(gainDb, MAX_ABS_GAIN_DB * 5.0) + 5));
        } else {
            roomParticipant.setMicrophoneLevel(RoomParticipant.DEFAULT_MICROPHONE_LEVEL);
        }
        roomParticipant.setJoinTime(new DateTime(state.get("connectTime")));

        String previewUrl = (String) state.get("previewURL");
        if (previewUrl != null && !previewUrl.isEmpty()) {
            String cacheId = roomId + ":" + roomParticipant.getId();
            synchronized (roomParticipantSnapshotUrlCache) {
                roomParticipantSnapshotUrlCache.put(cacheId, previewUrl);
            }
            roomParticipant.setVideoSnapshot(true);
        }

        // We can't get room layout, because it is current participant layout and not the configured one
        // (we need "cpLayout" attribute but it is missing).
        // If we configure a "GRID" layout, and only one participant is present the "currentLayout=1",
        // but we need "cpLayout=<grid-index>"
        //if (currentState.containsKey("currentLayout")) {
        //    roomParticipant.setLayout(getRoomLayoutByLayoutIndex((Integer) currentState.get("currentLayout")));
        //}
        return roomParticipant;
    }

    private void identifyParticipant(Command cmd, String roomId, String roomParticipantId) {
        RoomParticipantIdentifier roomParticipantIdentifier = new RoomParticipantIdentifier(roomParticipantId);
        cmd.setParameter("conferenceName", truncateString(roomId));
        cmd.setParameter("participantProtocol", truncateString(roomParticipantIdentifier.getParticipantProtocol()));
        cmd.setParameter("participantName", truncateString(roomParticipantIdentifier.getParticipantName()));
        // NOTE: it is necessary to identify a participant also by type; ad_hoc participants receive auto-generated
        //       numbers, so we distinguish the type by the fact whether the name is a number or not
        cmd.setParameter("participantType",
                (StringUtils.isNumeric(roomParticipantIdentifier.getParticipantName()) ? "ad_hoc" : "by_address"));
    }

    /**
     * @param roomId
     * @param roomParticipantId
     * @return snapshot {@link MediaData} for given {@code roomId} and {@code roomParticipantId}
     * @throws CommandException
     */
    private MediaData getRoomParticipantSnapshot(String roomId, String roomParticipantId) throws CommandException {
        String roomParticipantSnapshotUrl;
        String cacheId = roomId + ":" + roomParticipantId;
        synchronized (roomParticipantSnapshotUrlCache) {
            roomParticipantSnapshotUrl = roomParticipantSnapshotUrlCache.get(cacheId);
            if (roomParticipantSnapshotUrl == null) {
                Command cmd = new Command("participant.status");
                cmd.setParameter("operationScope", new String[] { "currentState" });
                identifyParticipant(cmd, roomId, roomParticipantId);
                Map<String, Object> result = execApi(cmd);
                @SuppressWarnings("unchecked")
                Map<String, Object> state = (Map<String, Object>) result.get("currentState");
                roomParticipantSnapshotUrl = (String) state.get("previewURL");
                if (roomParticipantSnapshotUrl == null) {
                    throw new CommandException("Participant " + roomParticipantId + " doesn't have snapshot.");
                }
                roomParticipantSnapshotUrlCache.put(cacheId, roomParticipantSnapshotUrl);
            }
        }

        logger.debug("Fetching snapshot for participant {} in room {}...", roomParticipantId, roomId);
        try {
            MediaData mediaData = execHttp(roomParticipantSnapshotUrl);
            MediaType mediaType = mediaData.getType();
            String type = mediaType.getType();
            if (mediaType.equals(MediaType.TEXT_PLAIN)) {
                String error = new String(mediaData.getData());
                if (error.contains("Unable to generate participant preview")) {
                    throw new CommandException("Cannot get snapshot for participant " + roomParticipantId
                            + ". Participant doesn't exist.");
                } else {
                    throw new CommandException(
                            "Cannot get snapshot for participant " + roomParticipantId + "." + error);
                }

            }
            if (!type.equals("image")) {
                throw new CommandException(
                        "Cannot get participant snapshot. Device returned " + mediaType + " instead of image.");
            }
            return mediaData;
        } catch (CommandException exception) {
            logger.warn(
                    "Retrieving snapshot for participant " + roomParticipantId + " in room " + roomId + " failed.",
                    exception);
            return null;
        }
    }

    /**
     * An example of interaction with the device.
     * <p/>
     * Just for debugging purposes.
     *
     * @param args
     * @throws IOException
     */
    public static void main(String[] args) throws IOException, CommandException, CommandUnsupportedException {
        BufferedReader in = new BufferedReader(new InputStreamReader(System.in));

        final String address;
        final String username;
        final String password;

        if (args.length > 0) {
            address = args[0];
        } else {
            System.out.print("address: ");
            address = in.readLine();
        }

        if (args.length > 1) {
            username = args[1];
        } else {
            System.out.print("username: ");
            username = in.readLine();
        }

        if (args.length > 2) {
            password = args[2];
        } else {
            System.out.print("password: ");
            password = in.readLine();
        }

        CiscoMCUConnector connector = new CiscoMCUConnector();
        connector.connect(DeviceAddress.parseAddress(address), username, password);

        // Participant snapshot
        //MediaData mediaData = connector.getRoomParticipantSnapshots("YY-shongo-local-qgotdi", "4");
        //System.out.println(mediaData.getType() + " " + mediaData.getData());

        // Room status by multiple threads
        /*List<Thread> threads = new LinkedList<Thread>();
        for (int i = 0; i < 2; i++ ) {
        Thread thread = new Thread() {
            @Override
            public void run()
            {
                try {
                    Room shongoTestRoom = conn.getRoom("shongo-test");
                    System.out.println("shongo-test room:");
                    System.out.println(shongoTestRoom);
                }
                catch (CommandException exception) {
                    exception.printStackTrace();
                }
                super.run();
            }
        };
        thread.start();
        threads.add(thread);
        }
        for (Thread thread : threads) {
        try {
            thread.join();
        }
        catch (InterruptedException exception) {
            exception.printStackTrace();
        }
        }*/

        // gatekeeper status
        //        Map<String, Object> gkInfo = conn.execApi(new Command("gatekeeper.query"));
        //        System.out.println("Gatekeeper status: " + gkInfo.get("gatekeeperUsage"));

        // test of listRooms() command
        //        Collection<RoomInfo> roomList = conn.listRooms();
        //        System.out.println("Existing rooms:");
        //        for (RoomInfo room : roomList) {
        //            System.out.printf("  - %s (%s, started at %s, owned by %s)\n", room.getCode(), room.getType(),
        //                    room.getStartDateTime(), room.getOwner());
        //        }

        // test that the second enumeration query fills data that has not changed and therefore were not transferred
        //        Command enumParticipantsCmd = new Command("participant.enumerate");
        //        enumParticipantsCmd.setParameter("operationScope", new String[]{"currentState"});
        //        enumParticipantsCmd.setParameter("enumerateFilter", "connected");
        //        List<Map<String, Object>> participants = conn.execApiEnumerate(enumParticipantsCmd, "participants");
        //        List<Map<String, Object>> participants2 = conn.execApiEnumerate(enumParticipantsCmd, "participants");

        // test that the second enumeration query fills data that has not changed and therefore were not transferred
        //        Command enumConfCmd = new Command("conference.enumerate");
        //        enumConfCmd.setParameter("moreThanFour", Boolean.TRUE);
        //        enumConfCmd.setParameter("enumerateFilter", "completed");
        //        List<Map<String, Object>> confs = conn.execApiEnumerate(enumConfCmd, "conferences");
        //        List<Map<String, Object>> confs2 = conn.execApiEnumerate(enumConfCmd, "conferences");

        // test of getRoom() command
        //        Room shongoTestRoom = conn.getRoom("shongo-test");
        //        System.out.println("shongo-test room:");
        //        System.out.println(shongoTestRoom);

        // test of deleteRoom() command
        //        Collection<RoomInfo> roomList = conn.listRooms();
        //        System.out.println("Existing rooms:");
        //        for (RoomInfo room : roomList) {
        //            System.out.println(room);
        //        }
        //        System.out.println("Deleting 'shongo-test'");
        //        conn.deleteRoom("shongo-test");
        //        roomList = conn.listRooms();
        //        System.out.println("Existing rooms:");
        //        for (RoomInfo room : roomList) {
        //            System.out.println(room);
        //        }

        // test of createRoom() method
        //        Room newRoom = new Room("shongo-test9", 5);
        //        newRoom.addAlias(new Alias(Technology.H323, AliasType.E164, "950087209"));
        //        newRoom.setOption(Room.OPT_DESCRIPTION, "Shongo testing room");
        //        newRoom.setOption(Room.OPT_LISTED_PUBLICLY, true);
        //        String newRoomId = conn.createRoom(newRoom);
        //        System.out.println("Created room " + newRoomId);
        //        Collection<RoomInfo> roomList = conn.listRooms();
        //        System.out.println("Existing rooms:");
        //        for (RoomInfo room : roomList) {
        //            System.out.println(room);
        //        }

        // test of bad caching
        //        Room newRoom = new Room("shongo-testX", 5);
        //        String newRoomId = conn.createRoom(newRoom);
        //        System.out.println("Created room " + newRoomId);
        //        Collection<RoomSummary> roomList = conn.listRooms();
        //        System.out.println("Existing rooms:");
        //        for (RoomSummary roomSummary : roomList) {
        //            System.out.println(roomSummary);
        //        }
        //        conn.deleteRoom(newRoomId);
        //        System.out.println("Deleted room " + newRoomId);
        //        Map<String, Object> atts = new HashMap<String, Object>();
        //        atts.put(Room.NAME, "shongo-testing");
        //        String changedRoomId = conn.modifyRoom("shongo-test", atts, null);
        //        Collection<RoomSummary> newRoomList = conn.listRooms();
        //        System.out.println("Existing rooms:");
        //        for (RoomSummary roomSummary : newRoomList) {
        //            System.out.println(roomSummary);
        //        }
        //        atts = new HashMap<String, Object>();
        //        atts.put(Room.NAME, "shongo-test");
        //        conn.modifyRoom(changedRoomId, atts, null);

        // test of modifyRoom() method
        //        System.out.println("Modifying shongo-test");
        //        Map<String, Object> atts = new HashMap<String, Object>();
        //        atts.put(Room.NAME, "shongo-testing");
        //        Map<Room.Option, Object> opts = new EnumMap<Room.Option, Object>(Room.Option.class);
        //        opts.put(Room.Option.LISTED_PUBLICLY, false);
        //        opts.put(Room.Option.PIN, "1234");
        //        conn.modifyRoom("shongo-test", atts, opts);
        //        Map<String, Object> atts2 = new HashMap<String, Object>();
        //        atts2.put(Room.ALIASES, Collections.singletonList(new Alias(Technology.H323, AliasType.E164, "950087201")));
        //        atts2.put(Room.NAME, "shongo-test");
        //        conn.modifyRoom("shongo-testing", atts2, null);

        // test of listRoomParticipants() method
        //        System.out.println("Listing shongo-test room:");
        //        Collection<RoomParticipant> shongoUsers = conn.listRoomParticipants("shongo-test");
        //        for (RoomParticipant ru : shongoUsers) {
        //            System.out.println("  - " + ru.getUserId() + " (" + ru.getDisplayName() + ")");
        //        }
        //        System.out.println("Listing done");

        // user connect by alias
        //        String ruId = conn.dialRoomParticipant("shongo-test", new Alias(Technology.H323, AliasType.E164, "950081038"));
        //        System.out.println("Added user " + ruId);
        // user connect by address
        //        String ruId2 = conn.dialRoomParticipant("shongo-test", "147.251.54.102");
        // user disconnect
        //        conn.disconnectRoomParticipant("shongo-test", "participant1");

        //        System.out.println("All done, disconnecting");

        // test of modifyRoomParticipant
        //        Map<String, Object> attributes = new HashMap<String, Object>();
        //        attributes.put(RoomParticipant.VIDEO_ENABLED, Boolean.TRUE);
        //        attributes.put(RoomParticipant.DISPLAY_NAME, "Ondrej Bouda");
        //        conn.modifyRoomParticipant("shongo-test", "3447", attributes);

        //Room room = conn.getRoom("shongo-test");

        connector.disconnect();
    }

    /**
     * Cache storing results from a single command.
     * <p/>
     * Stores the revision number and the corresponding result set.
     * <p/>
     * The items stored in the cache are compared just according to their unique identifiers. They may differ in other
     * attributes. The reason for this is to provide simple searching for an item - the cache is given an item which has
     * just its unique ID, and should find the previously stored, full version of the item. Hence comparing just
     * according to the IDs.
     * <p/>
     * If the item contains a "participantName" key, the value under this key is used as the item unique ID.
     * If the item contains a "conferenceName" key, the value under this key is used as the item unique ID.
     * Otherwise, only items with equal contents are considered equal.
     */
    private class ResultsCache {
        private class Item {

            private final Map<String, Object> contents;

            public Item(Map<String, Object> contents) {
                if (contents == null) {
                    throw new NullPointerException("contents");
                }

                this.contents = contents;
            }

            public Map<String, Object> getContents() {
                return contents;
            }

            @Override
            public boolean equals(Object o) {
                if (this == o) {
                    return true;
                }
                if (o == null || getClass() != o.getClass()) {
                    return false;
                }

                Item item = (Item) o;

                final Object participantName = contents.get("participantName");
                if (participantName != null) {
                    return (participantName.equals(item.contents.get("participantName")));
                }

                final Object conferenceName = contents.get("conferenceName");
                if (conferenceName != null) {
                    return (conferenceName.equals(item.contents.get("conferenceName")));
                }

                return contents.equals(item.contents);
            }

            @Override
            public int hashCode() {
                final Object participantName = contents.get("participantName");
                if (participantName != null) {
                    return participantName.hashCode();
                }
                final Object conferenceName = contents.get("conferenceName");
                if (conferenceName != null) {
                    return conferenceName.hashCode();
                }
                return contents.hashCode();
            }

        }

        private int revision;

        private List<Item> results;

        public int getRevision() {
            return revision;
        }

        public Map<String, Object> getItem(Map<String, Object> item) {
            final Item it = new Item(item);
            // FIXME: optimize - should be O(1) rather than O(n)
            for (Item cachedItem : results) {
                if (cachedItem.equals(it)) {
                    return cachedItem.getContents();
                }
            }
            return null;
        }

        public void store(int revision, List<Map<String, Object>> results) {
            this.revision = revision;
            this.results = new ArrayList<Item>(results.size());
            for (Map<String, Object> res : results) {
                this.results.add(new Item(res));
            }
        }

    }

    /**
     * @param layoutIndex   index of the layout as defined by Cisco
     * @return room layout according to the given Cisco layout index
     */
    private static RoomLayout getRoomLayoutByLayoutIndex(int layoutIndex) {
        switch (layoutIndex) {
        case 1:
            return RoomLayout.SPEAKER;
        case 2:
        case 3:
        case 4:
        case 8:
        case 9:
            return RoomLayout.GRID;
        case 5:
        case 6:
        case 7:
            return RoomLayout.SPEAKER_CORNER;
        default:
            return RoomLayout.OTHER;
        }
    }

    /**
     * @param roomLayout
     * @return Cisco layout index according to the given room layout
     */
    private static Integer getLayoutIndexByRoomLayout(RoomLayout roomLayout) {
        switch (roomLayout) {
        case OTHER:
            return null;
        case SPEAKER:
            return 1;
        case SPEAKER_CORNER:
            return 5;
        case GRID:
            return 3;
        default:
            throw new TodoImplementException(roomLayout);
        }
    }

    private static class RoomParticipantIdentifier {
        private final String participantProtocol;

        private final String participantName;

        public RoomParticipantIdentifier(Map<String, Object> participant) {
            this.participantProtocol = (String) participant.get("participantProtocol");
            this.participantName = (String) participant.get("participantName");
        }

        public RoomParticipantIdentifier(String roomParticipantId) {
            String[] parts = roomParticipantId.split(":");
            if (parts.length != 2) {
                throw new IllegalArgumentException("Room participant id must in format '<protocol>:<name>'.");
            }
            this.participantProtocol = parts[0];
            this.participantName = parts[1];
        }

        public String getParticipantProtocol() {
            return participantProtocol;
        }

        public String getParticipantName() {
            return participantName;
        }

        @Override
        public String toString() {
            return participantProtocol + ":" + participantName;
        }
    }
}