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 (likely44100
) -
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 theVolumeDecibel
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");
}