Trinnov Altitude Plugin

This is a plugin for Trinnov Altitude audio processors using direct IP control (since the Trinnov cannot be powered on over RS232, I didn’t bother to test it with a Global Cache IP2SL).

This plugin is based on the official Trinnov Altitude Processor Automation Protocol (v1.15) document, available at: https://www.trinnov.com/en/products/altitude-sup-16-sup/#Downloads

This plugin has 3 Settings that can be configured:

  • Host - the hostname or IP address to connect to
  • Post - the port to connect to (likely 44100)
  • MAC - the MAC address of the Ethernet interface of the Trinnov (for Wake-on-LAN messages)

The supported MediaCommands are:

  • PowerOn / PowerOff
  • VolumeUp / VolumeDown - changes volume in 0.5 dB increments
  • Mute - toggles the mute state
  • MuteOn / MuteOff - sets the mute state
  • On / Off / Toggle suffixes for (for example, VolumeDimOn):
    • VolumeDim
    • Bypass
    • Optimizier
    • AcousticCorrection
    • LevelAlignment
    • TimeAlignment
    • FrontPanelDisplay
  • RemappingMode suffixes (for example, RemappingMode3D):
    • None
    • 2D
    • 3D
    • AutoRoute
    • Manual
  • Upmixer suffixes (for example, UpmixerDolby):
    • Next
    • Previous
    • Auro3D
    • DTS
    • Dolby
    • Auto
    • Native
    • Legacy
    • UpmixOnNative

In addition, this plugin supports the following Capabilities:

  • AudioMute
  • AudioVolumeDecibel (volume controlled through the VolumeDecibel Attribute)
  • MediaInputSource
  • Switch

The AudioVolume Capability (which provides the Volume Attribute) is not provided because the Trinnov uses decibels for its native volume values and the conversion to a 0-100 scale is not clear, due to the ability to set an arbitrary maximum volume on the Trinnov.

The InputSource Attribute uses the names of the input sources (profiles) and those are available through the SupportedInputSources Attribute.

The CurrentPreset Attribute uses the names of the presets (labels) and those are available through the SupportedPresets Attribute.

The following feedbacks are provided through these Attributes (On / Off unless otherwise specified):

  • Dim
  • Bypass
  • SampleRate - sample rate
  • AudioSyncStatus - Yes / No
  • AudioSyncMode - sync mode
  • SpeakerInfo - have not been able to test this yet, so it may not work
  • ReadyToRun - True / False
  • Optimizer
  • AcousticCorrection
  • LevelAlignment
  • TimeAlignment
  • Decoder - name of decoder being used
  • Upmixer - name of upmixer being used
  • RemappingMode - name of remapping mode being used
  • CurrentPreset - name of current preset
  • SupportedPresets - list of names of supported presets
  • FrontPanelDisplayMode

I’ve just gotten my Trinnov up and running, and it still needs to be calibrated and fine-tuned, so there may well be additional commands and feedbacks added, although I got almost everything documented in the reference linked above. I’m definitely open to requests on other things to add, however. Also, I have an Altitude 16, so it is possible other versions may have different functionality that I am unable to test.

One additional note–usually in my plugins, I strip out the Advanced Plugin Logging capabilities to keep them “clean”. However, I run all of my plugins with that code, so I’m going to leave it in because it makes debugging so much easier when running in the app. If you don’t want it, it is pretty easy to strip out.

Change Log:

  • 2023-01-26 - original version
  • 2023-10-07 - added support for selecting presets through the CurrentPreset Attribute
  • 2023-10-08 - tweaked SpeakerInfo Attribute contents so can be displayed correctly
  • 2023-10-09 - fixed a bug in how speaker information is maintained

Last updated: 2023-10-09

Here is the plugin code:
TrinnovAltitude-2023-10-09.plugin (23.0 KB)

plugin.Name = "TrinnovAltitude";
plugin.OnChangeRequest = onChangeRequest;
plugin.OnConnect = onConnect;
plugin.OnDisconnect = onDisconnect;
plugin.OnPoll = onPoll;
plugin.OnSynchronizeDevices = onSynchronizeDevices;
plugin.PollingInterval = 500;
plugin.DefaultSettings = {
    "Host"          : "192.168.1.1",
    "Port"          : "44100",
    "MAC"           : "11:22:33:44:55:66",      // for Wake-on-LAN
};

var VERSION = "2023-10-09";

/*
    This plugin is based on the official Trinnov Altitude Processor Automation
      Protocol (v1.15) document, available at:
        https://www.trinnov.com/en/products/altitude-sup-16-sup/#Downloads

    Most of the Capabilities and Attributes are fairly self-explanatory.  Note
      that the AudioVolume Capability (which provides the Volume attribute) is
      not provided because the Trinnov uses decibels for its native volume
      values and the conversion to a 0-100 scale is not clear, due to the
      ability to set an arbitrary maximum volume on the Trinnov.
*/

// Change Log:
// * 2023-01-26 - initial version
// * 2023-10-07 - added support for selecting presets through the CurrentPreset attribute
// * 2023-10-08 - tweaked SpeakerInfo attribute contents so can be displayed correctly
// * 2023-10-09 - fixed a bug in how speaker information is maintained

var socket = new TCPClient();

var REMOTE_IDENTIFIER       = "Home Remote (TrinnovAltitude Plugin)";
var trinnovWelcomeReceived  = false;


var debug = true;
var LOG_MAX = 5000;  // maximum size of the log in characters; set to 0 for unlimited
var LOG_TRIM = 500;  // when maximum size reached, trim the log plus this much more so not constantly trimming


var mediaCommandMappings = {
    "PowerOff"                  : "power_off_SECURED_FHZMCH48FE",
    "PowerOn"                   : "WOL",
    "VolumeUp"                  : "dvolume 0.5",
    "VolumeDown"                : "dvolume -0.5",
    "Mute"                      : "mute 2",     // this is a toggle

    "MuteOn"                    : "mute 1",
    "MuteOff"                   : "mute 0",

    "VolumeDimOn"               : "dim 1",
    "VolumeDimOff"              : "dim 0",
    "VolumeDimToggle"           : "dim 2",
    "BypassOn"                  : "bypass 1",
    "BypassOff"                 : "bypass 0",
    "BypassToggle"              : "bypass 2",
    "RemappingModeNone"         : "remapping_mode none",
    "RemappingMode2D"           : "remapping_mode 2D",
    "RemappingMode3D"           : "remapping_mode 3D",
    "RemappingModeAutoRoute"    : "remapping_mode autoroute",
    "RemappingModeManual"       : "remapping_mode manual",
    "OptimizerOn"               : "quick_optimized 1",
    "OptimizerOff"              : "quick_optimized 0",
    "OptimizerToggle"           : "quick_optimized 2",
    "AcousticCorrectionOn"      : "use_acoustics_correction 1",
    "AcousticCorrectionOff"     : "use_acoustics_correction 0",
    "AcousticCorrectionToggle"  : "use_acoustics_correction 2",
    "LevelAlignmentOn"          : "use_level_alignment 1",
    "LevelAlignmentOff"         : "use_level_alignment 0",
    "LevelAlignmentToggle"      : "use_level_alignment 2",
    "TimeAlignmentOn"           : "use_time_alignment 1",
    "TimeAlignmentOff"          : "use_time_alignment 0",
    "TimeAlignmentToggle"       : "use_time_alignment 2",
    "UpmixerNext"               : "upmixer +",
    "UpmixerPrevious"           : "upmixer -",
    "UpmixerAuro3D"             : "upmixer auro3d",
    "UpmixerDTS"                : "upmixer dts",
    "UpmixerDolby"              : "upmixer dolby",
    "UpmixerAuto"               : "upmixer auto",
    "UpmixerNative"             : "upmixer native",
    "UpmixerLegacy"             : "upmixer legacy",
    "UpmixerUpmixOnNative"      : "upmixer upmix_on_native",
    "FrontPanelDisplayOn"       : "fav_light 1",
    "FrontPanelDisplayOff"      : "fav_light 0",
    "FrontPanelDisplayToggle"   : "fav_light 2",
};

// mappings between profile (input) numbers to names, since we have to be able to look up both ways
// this is populated with data from the Trinnov itself
var inputSourceNumberToNameMappings = {};
var inputSourceNameToNumberMappings = {};

// mappings between label (preset) numbers to names, since we have to be able to look up both ways
// this is populated with data from the Trinnov itself
var presetNumberToNameMappings = {};
var presetNameToNumberMappings = {};

// speaker info list
// this is populated with data from the Trinnov itself
var speakerInfo = [];


function debugLog(s) {
    // Logs a string to both the console and the device Log attribute
    //  because the console is not available in the app.  It can also
    //  trim the device Log to keep it from growing too large if
    //  LOG_MAX is not 0.
    if(debug) {
        var device = plugin.Devices[plugin.Name];

        // is this the first time through?
        if(device && !device.Log) {
            // log is empty, so start it off with version info
            var verstr = plugin.Name + ": version " + VERSION;
            console.log(verstr);
            device.Log = verstr + "\n";
        }

        var logstr = plugin.Name + ": " + s;

        // send it to the console
        console.log(logstr);

        // send it to the device log
        if(device && device.Log) {
            device.Log += logstr + "\n";
            
            // trim the log if necessary
            if(LOG_MAX && (device.Log.length > LOG_MAX)) {
                device.Log = device.Log.slice(-(LOG_MAX + LOG_TRIM));
            }
        }
    }
}


// helper function to make strings (those with non-printable characters) more readable
function stringToReadableString(chars) {
    var str = "";

    for(var i = 0; i < chars.length; i++) {
        var c = chars[i];
        var b = c.charCodeAt(0);
        if((b < 32) || (b > 126)) {
            // non-printable character, indicate hex value
            str += "[0x" + ("0" + b.toString(16)).slice(-2) + "]"
        } else {
            str += c;
        }
    }
    return str;
}


// helper function to connect to Trinnov
function trinnovConnect() {

    if(socket.isConnected) {
        // already connected, nothing to do
        return;
    }

    // the Trinnov may not be on, but let's try and if the connection fails, assume it is off
    var host        = plugin.Settings["Host"];
    var port        = plugin.Settings["Port"];
    var identifier  = REMOTE_IDENTIFIER;

    debugLog("  Host: " + host);
    debugLog("  Port: " + port);
    debugLog("  ID  : " + identifier);

    var device = plugin.Devices[plugin.Name];
    
    if(!device) {
        // not fully configured yet, so just return for now
        return;
    }

    debugLog("  attempting to connect...");
    try {
        socket.connect(host, port);

    } catch(err) {
        // unable to connect, probably not on (might be booting up, but not easy to know)
        debugLog("WARNING: unable to connect to Trinnov: " + err.message);
        device.Switch = "Off";
        return;
    }

    // if we got this far, we're connected, so it must be powered on
    debugLog("  connected");
    device.Switch = "On";

    // sleep briefly for welcome message to be sent back to process
    sleep(100);

    // process receive the connection welcome message
    var messages = trinnovProcessMessages();

    if(!trinnovWelcomeReceived) {
        // weird, it didn't say welcome like it was supposed to.  something may be wrong but press forward anyway
        debugLog("WARNING: Trinnov did not respond with expected Welcome message on connection.  Something may be wrong, but attempting to continue...");

    } else {
        // received the welcome, so clear the flag for next reconnection
        trinnovWelcomeReceived = false;
    }

    // Send our identifier to complete the initial connection handshake
    debugLog("  sending ID: '" + REMOTE_IDENTIFIER + "'");
    socket.send("id " + REMOTE_IDENTIFIER + "\n");
    
    debugLog("  connected");

    // get current state and profile data
    debugLog("  requesting state information...");
    socket.send("get_current_state\n");

    // sleep briefly for all data to be sent back to process
    sleep(100);

    // process received messages
    debugLog("  processing initial messages...");
    trinnovProcessMessages();
    debugLog("  done.");

    // all done
}


// helper function to send message
function trinnovSendMessage(cmd) {
    // make sure we're connected
    trinnovConnect();

    if(socket.isConnected) {
        // send the command
        debugLog("sending command: " + cmd + "...");
        socket.send(cmd + "\n");

    } else {
        debugLog("WARNING: unable to connect to Trinnov, not sending command: '" + cmd + "'...");
    }

    // all done
}

// helper function to receive messages
function trinnovReceiveMessages() {
    var response = "";
    var messages = [];

    if(socket.available) {
        response = socket.receive({ timeout: 500 });
        messages = response.trim().split("\n");
    }

    // all done
    return(messages);
}

// helper function to process any outstanding messages
function trinnovProcessMessages() {
    var device  = plugin.Devices[plugin.Name];

    if(device) {

        var messages        = [];
        var messageString   = "";
        var message         = [];

        // get the messages to process
        messages = trinnovReceiveMessages();

        // process each received message
        for(var m = 0; m < messages.length; m++) {
            messageString = messages[m];
            message = messageString.split(" ");

            debugLog("  received message: [" + message + "]");

            switch(message[0]) {
                case "OK":
                    // command was fine, just ignore this
                    break;

                case "VOLUME":
                    // trim to 2 decimal places
                    device.VolumeDecibel = parseFloat(message[1]).toFixed(2);
                    break;

                case "DIM":
                    device.Dim = (message[1] == "1") ? "On" : "Off";
                    break;

                case "MUTE":
                    device.Mute = (message[1] == "1") ? "Muted" : "Unmuted";
                    break;

                case "BYPASS":
                    device.Bypass = (message[1] == "1") ? "On" : "Off";
                    break;
                case "META_PRESET_LOADED":
                case "CURRENT_PROFILE":
                    // this identifies the current profile (input)
                    device.InputSource = inputSourceNumberToNameMappings[message[1]];
                    break;
                case "SRATE":
                    device.SampleRate = message[1];
                    break;

                case "AUDIOSYNC_STATUS":
                    device.AudioSyncStatus = (message[1] == "1") ? "Yes" : "No";
                    break;

                case "AUDIOSYNC":
                    device.AudioSyncMode = message[1];
                    break;

                case "SPEAKER_INFO":
                    // store info for the speaker
                    speakerInfo.push(message[1] /* speaker number */, message[2] /*r*/, message[3] /*theta*/, message[4] /*phi*/);
                    device.SpeakerInfo = speakerInfo;
                    break;

                case "START_RUNNING":
                    device.ReadyToRun = "True";
                    break;
                    
                case "LABELS_CLEAR":    // labels are presets
                    presetNumberToNameMappings  = {};
                    presetNameToNumberMappings  = {};
                    device.SupportedPresets     = [];
                    break;

                case "LABEL":           // labels are presets
                    // this is naming information for the label (preset)
                    // trim the colon off the end to get the number and treat
                    // the rest as the name (which may have spaces in it)
                    var presetNum = message[1].slice(0, -1);
                    var presetName = message.slice(2).join(" ");

                    // add the label (preset) number <-> name mappings
                    presetNumberToNameMappings[presetNum]   = presetName;
                    presetNameToNumberMappings[presetName]  = presetNum;

                    // add the label (preset) name to the supported presets
                    device.SupportedPresets = Object.keys(presetNameToNumberMappings);
                    break;

                case "CURRENT_PRESET":
                    // look up the current preset name
                    var presetName = presetNumberToNameMappings[message[1]];
                    if(presetName) {
                        device.CurrentPreset = presetName;
                    } else if(message[1] == "-1") {
                        // no current preset
                        device.CurrentPreset = "-1";
                    }else {
                        debugLog("ERROR: unrecognized preset specified in CURRENT_PRESET: " + message[1]);
                    }
                    break;

                case "PROFILES_CLEAR":  // profiles are inputs
                    // clear the mappings
                    inputSourceNumberToNameMappings = {};
                    inputSourceNameToNumberMappings = {};
                    device.SupportedInputSources    = [];
                    break;

                case "PROFILE":         // profiles are inputs
                    // if the profile number doesn't end in a colon, it is
                    // telling us the current profile (input), otherwise it is
                    // naming all of them
                    if(message[1].indexOf(":") != -1) {
                        // this is naming information for the profile (input)
                        // trim the colon off the end to get the number and
                        // treat the rest as the name (which has spaces in it)
                        var profileNum = message[1].slice(0, -1);
                        var profileName = message.slice(2).join(" ");

                        // add the profile number <-> name mappings
                        inputSourceNumberToNameMappings[profileNum]     = profileName;
                        inputSourceNameToNumberMappings[profileName]    = profileNum;

                        // add the profile name to the support input sources
                        device.SupportedInputSources = Object.keys(inputSourceNameToNumberMappings);
                    } else {
                        // this is the current profile (input)
                        var profileNum = message[1];
                        var profileName = inputSourceNumberToNameMappings[profileNum];

                        // only set it if we know what it is
                        if(profileName) {
                            device.InputSource = profileName;
                        }
                    }
                    break;

                case "FAV_LIGHT":       // front panel display mode
                    device.FrontPanelDisplayMode = (message[1] == "0") ? "On" : "Off";
                    break;

                case "OPTIMIZATION":    // optimizer-related information
                    // format of line is: OPTIMIZATION <0|1>, CORRECTION <0|1>, LEVEL_ALIGNMENT <0|1>, TIME_ALIGNMENT <0|1>
                    // need to strip trailing commas
                    device.Optimizer            = (message[1].slice(0, -1) == "1") ? "On" : "Off";
                    device.AcousticCorrection   = (message[3].slice(0, -1) == "1") ? "On" : "Off";
                    device.LevelAlignment       = (message[5].slice(0, -1) == "1") ? "On" : "Off";
                    device.TimeAlignment        = (message[7] == "1") ? "On" : "Off";
                    break;

                case "DECODER":         // decoder and upmixer information
                    // format of line is: DECODER NONAUDIO <na> PLAYABLE <playable> DECODER <decoder> UPMIXER <upmixer>
                    // we only extract <decoder> and <upmixer>, but note that decoder name might have a space in it, so use indexes of keywords
                    var decoderPos  = message.indexOf("DECODER", 1);
                    var upmixerPos  = message.indexOf("UPMIXER");
                    device.Decoder   = message.slice(decoderPos+1, upmixerPos).join(" ");
                    device.Upmixer   = message.slice(upmixerPos+1).join(" ");
                    break;

                case "REMAPPING_MODE":  // remapping mode
                    device.RemappingMode = message[1];
                    break;

                case "ERROR:":
                    debugLog("ERROR: received error message: " + messageString);
                    break;

                case "Welcome":
                    trinnovWelcomeReceived = true;
                    break;

                case "BYE":
                    // we must have closed the connection, nothing to do
                    break;

                default:
                    // these are commands that we recognize but aren't going to do anything with, so just ignore
                    break;
            }

            // done with that message
        }
    }

    // all done
}

function onChangeRequest(device, attribute, value) {

    var cmd = "";

    // handle the various capabilities we expose
    switch(attribute) {
        case "Mute":
            if(value == "Muted") {
                cmd = mediaCommandMappings["MuteOn"];
            } else if(value == "Unmuted") {
                cmd = mediaCommandMappings["MuteOff"];
            } else {
                debugLog("ERROR: unknown mute value: " + value);
            }
            break;

        case "VolumeDecibel":
            cmd = "volume " + value;
            break;

        case "MediaCommand":
            cmd = mediaCommandMappings[value];
            break;

        case "InputSource":
            var newInput = inputSourceNameToNumberMappings[value];
            if(newInput) {
                cmd = "profile " + newInput;

                // when changing a profile, need to clear the ready-to-run indicator and reset the speaker info
                device.ReadyToRun   = "False";
                speakerInfo         = [];
                device.SpeakerInfo  = [];
            } else {
                debugLog("ERROR: unknown InputSource: " + value);
            }
            break;

        case "CurrentPreset":
            var newPreset = presetNameToNumberMappings[value];
            if(newPreset) {
                cmd = "loadp " + newPreset;

                // when changing a preset, need to clear the ready-to-run indicator and reset the speaker info
                device.ReadyToRun   = "False";
                speakerInfo         = [];
                device.SpeakerInfo  = [];
            } else {
                debugLog("ERROR: unknown Preset: " + value);
            }
            break;
    
        case "Switch":
            if(value == "On") {
                cmd = mediaCommandMappings["PowerOn"];
            } else {
                cmd = mediaCommandMappings["PowerOff"];
            }
            break;

        default:
            debugLog("ERROR: unsupported attribute type: " + attribute);
            break;
    }

    // processed the command, so send if it is valid
    if(cmd) {
        if(cmd == "WOL") {
            var mac = plugin.Settings["MAC"];

            debugLog("  MAC     : " + mac);

            debugLog("  sending WOL to: " + mac);
            WOL.wake(mac);

            // spin here until we connect, because we can't do anything else until then anyway
            while(!socket.isConnected) {
                // doesn't start up right away, so sleep briefly
                sleep(5 * 1000);    /* 5s delay */

                // try to connect
                trinnovConnect();
            }

        } else {
            // send the message
            trinnovSendMessage(cmd);
        }

        if(cmd == "power_off_SECURED_FHZMCH48FE") {
            // pause briefly for the shutdown process to get going and treat Trinnov as off
            sleep(5 * 1000);    /* 5s delay */
            device.Switch   = "Off";
        }

        // process any responsive messages
        trinnovProcessMessages();

    } else {
        debugLog("ERROR: unsupported command: " + value);
    }

    // all done
}

function onConnect() {
    debugLog("onConnect called...");

    var device = plugin.Devices[plugin.Name];

    if(device) {
        debugLog("  setting supported media commands...");
        device.SupportedMediaCommands = Object.keys(mediaCommandMappings);
        debugLog("  set (" + device.SupportedMediaCommands.length + "): " + device.SupportedMediaCommands);
    }

    // connect if possible
    trinnovConnect();

    // all done
}

function onDisconnect() {
    debugLog("onDisconnect called...");

    // clean up any remaining messages
    trinnovProcessMessages();

    if(socket.isConnected) {
        debugLog("  sending bye command...");
        socket.send("bye\n");
    
        try {
            debugLog("  closing socket...");
            socket.close();

        } catch(err) {
            // we're closing down anyway, so it doesn't matter if the close()
            //  failed--just fail silently
        }
    }

    // clear state
    trinnovWelcomeReceived = false;

    debugLog("  disconnected");
}

function onPoll() {
    // make sure we're connected
    trinnovConnect();

    // see if there are any messages to process
    trinnovProcessMessages();
}

function onSynchronizeDevices() {
    debugLog("onSynchronizeDevices called...");

    var device = new Device();

    device.Id = plugin.Name;
    device.DisplayName = "Trinnov Altitude";
    device.Icon = "Receiver";
    device.Capabilities = [ "AudioMute", "AudioVolumeDecibel", "MediaControl", "MediaInputSource", "Switch" ];
    device.Attributes = [   "Dim",
                            "Bypass",
                            "SampleRate",
                            "AudioSyncStatus",
                            "AudioSyncMode",
                            "SpeakerInfo",
                            "ReadyToRun",
                            "Optimizer",
                            "AcousticCorrection",
                            "LevelAlignment",
                            "TimeAlignment",
                            "Decoder",
                            "Upmixer",
                            "RemappingMode",
                            "CurrentPreset",
                            "SupportedPresets",
                            "FrontPanelDisplayMode",
                            "Log",
                        ];

    plugin.Devices[plugin.Name] = device;

    debugLog("  done syncing");
}

A small update, now that my Trinnov has been calibrated :slight_smile:, I added support for selecting presets (and not just seeing the current and available ones) using the CurrentPreset Attribute (works very similar to the InputSource Attribute).

Another small update, I tweaked how the SpeakerInfo Attribute stores the information, so now it can actually be displayed in a GridView control.

Third time’s the charm… fixed a bug in how speaker information was stored internally which resulted in duplication every time the profile was changed.