Monoprice Multizone Audio Controller

Attached is a Plugin for a Multizone Audio Controller. This was originally developed for the Monoprice 6 Zone controller but there several other controllers this plugin should be compatible with. The following products all appear to use the same control protocol.

This setup also requires an IP to Serial converter so it can be controlled over a standard network connection. You can use a product like the Global Cache IP2SL.
https://www.globalcache.com/products/itach/ip2slspecs

To install the plugin, use the Plugin Import utility in the Designer. The import process will ask you for the IP Address & Port of the IP to Serial converter. The default port for Global Cache serial converters is port 4999. It will also ask you for the number of zones, default is 6.

Here are the custom XAML templates that were built for the plugin:
AudioZoneTile.xaml (3.1 KB)
AudioZoneDetails.xaml (12.3 KB)

Here is the plugin code:
MultizoneAudioController.plugin (5.1 KB)

plugin.Name = "MultizoneAudioController";
plugin.OnChangeRequest = onChangeRequest;
plugin.OnConnect = onConnect;
plugin.OnDisconnect = onDisconnect;
plugin.OnPoll = onPoll;
plugin.OnSynchronizeDevices = onSynchronizeDevices;
plugin.PollingInterval = 250;
plugin.DefaultSettings = { "Host": "192.168.1.100", "Port": "4999", "Zones": "6" };

var socket = new TCPClient();

var unprocessedData = "";
var rawVolume;

function onChangeRequest(device, attribute, value) {
    var cmd = null;
    switch (attribute) {
        case "MediaCommand":
            if (value == "VolumeUp") {
                cmd = "VO" + (rawVolume + 1).toString().padStart(2, '0');
            }
            else if (value == "VolumeDown") {
                cmd = "VO" + (rawVolume - 1).toString().padStart(2, '0');
            }
            else {
                throw "not implemented";
            }
            break;
        case "InputSource":
            cmd = "CH" + parseInt(value).toString().padStart(2, '0');
            break;
        case "Mute":
            cmd = "MU" + ((value == "Muted") ? "01" : "00");
            break;
        case "Switch":
            cmd = "PR" + ((value == "On") ? "01" : "00");
            break;
        case "Volume":
            cmd = "VO" + parseInt((value / 100) * 38).toString().padStart(2, '0');
            break;
        case "Trebel":
            cmd = "TR" + parseInt((value)).toString().padStart(2, '0');
            break;
        case "Bass":
            cmd = "BS" + parseInt((value)).toString().padStart(2, '0');
            break;
        case "Balance":
            cmd = "BL" + parseInt((value)).toString().padStart(2, '0');
            break;
        default:
            throw "Not implemented!";
    }
    socket.send("<" + device.Id + cmd + "\r");
}

function onConnect() {
    socket.connect(plugin.Settings["Host"], parseInt(plugin.Settings["Port"]));
    socket.send("?10PR\r");
    socket.send("?10MU\r");
    socket.send("?10VO\r");
    socket.send("?10CH\r");
    socket.send("?10TR\r");
    socket.send("?10BS\r");
    socket.send("?10BL\r");
}

function onDisconnect() {
    socket.close();
}

function onPoll() {
    var options = { timeout: -1 };
    var data = socket.receive(options);
    data = unprocessedData + data;
    unprocessedData = "";
    if (data.length > 0) {
        var lines = data.split("\r\n");
        for (var l in lines) {
            var line = lines[l];
            //The last line should always be '#'.  If it isn't we know it's a partial line that needs to be included in next polling loop.
            if (l == (lines.length - 1) && line != "#") {
                unprocessedData = line;
                return;
            }
            console.log(line);
            var item = line;
            if (line.length >= 7) {
                var item = (line[0] == '#') ? line.substring(1) : line;
                var zone = item.substring(1, 3);
                var cmd = item.substring(3, 5);
                var value = item.substring(5, 7);
                var device = plugin.Devices[zone];
                if (device != null) {
                    switch (cmd) {
                        case "CH":
                            device.InputSource = parseInt(value);
                            break;
                        case "MU":
                            device.Mute = ((value == "01") ? "Muted" : "Unmuted");
                            break;
                        case "PR":
                            device.Switch = ((value == "01") ? "On" : "Off");
                            break;
                        case "VO":
                            rawVolume = parseInt(value);
                            device.Volume = parseInt((value / 38) * 100);
                            break;
                        case "TR":
                            device.Trebel = parseInt((value));
                            break;
                        case "BS":
                            device.Bass = parseInt((value));
                            break;
                        case "BL":
                            device.Balance = parseInt((value));
                            break;
                        default:
                            return;
                    }
                }
            }
        }
    }
}

function onSynchronizeDevices() {
    //Generate zones 1-6 of Main unit 1.
    //First digit in Id is the unit number.
    //Second digit in Id is the zone number.
    var zoneCount = parseInt(plugin.Settings["Zones"]);
    for (var i = 1; i <= zoneCount; i++) {
        var device = new Device();
        device.Id = "1" + i.toString();
        device.DisplayName = "Zone " + i.toString();
        device.Icon = "Speaker";
        device.Capabilities = ["AudioMute", "AudioVolume", "MediaControl", "MediaInputSource", "Switch"];
        device.Attributes = ["Trebel", "Bass", "Balance"];
        device.DeviceType = "AudioZone";
        device.TileTemplate = "AudioZoneTile.xaml";
        device.DetailsTemplate = "AudioZoneDetails.xaml";

        plugin.Devices[device.Id] = device;
    }
}

I have mine connected into my network using a serial to USB cable into a Raspberry Pi. I run ser2net. Since I already have several Raspberry Pis in my setup, the cable was a much cheaper integration than a Global Cache device. Glad to help with anyone trying to set it up this way.

1 Like