From 6ff023bdd3ca185c6c51ebc66c55f845f9c78d6a Mon Sep 17 00:00:00 2001 From: Justin Parker Date: Wed, 31 Jul 2024 12:48:48 +1000 Subject: [PATCH] Update script.py (#84) --- Biamp/Mk2 - Tesira (and Nexia)/script.py | 413 ++++++++++++++++++++--- 1 file changed, 359 insertions(+), 54 deletions(-) diff --git a/Biamp/Mk2 - Tesira (and Nexia)/script.py b/Biamp/Mk2 - Tesira (and Nexia)/script.py index 36a1cd7..76856aa 100644 --- a/Biamp/Mk2 - Tesira (and Nexia)/script.py +++ b/Biamp/Mk2 - Tesira (and Nexia)/script.py @@ -1,13 +1,36 @@ -'''Works with Tesira models using the Tesira Text Protocol (TTP)''' +# -*- coding: utf-8 -*- +u''' +**Biamp Tesira Text Protocol** (TTP) - Works with Tesira models using the Tesira Text Protocol. + +Make sure TELNET is turned on. + +`rev 10.20240319` + +_changelog_ + + * r10: added Logic Meter + * r9: general faults raise warning and automatic connection drop + * r8: activeFaults + * r7: firmware version, start/stop pollers + * r6: added Router block + * r5: parse **ObjectIDs.csv** file, added Presets via Preset Recall + * r4: logic selector block, Parlé mic block + +''' # TODO: # - For Maxtrix Mixer blocks, only cross-point muting is done. +# - named source select +# - mute with source select TELNET_TCPPORT = 23 param_Disabled = Parameter({'schema': {'type': 'boolean'}}) param_IPAddress = Parameter({'title': 'IP address', 'schema': {'type': 'string'}}) +# this is the general error count used to determine errors p. minute +_errorCount = 0 + # TODO REMOVE DEFAULT_DEVICE = 1 param_InputBlocks = Parameter({'title': 'Input blocks', 'schema': {'type': 'array', 'items': {'type': 'object', 'properties': { @@ -22,12 +45,17 @@ 'instance': {'type': 'string', 'desc': 'Instance ID or tag', 'order': 1}, 'names': {'type': 'string', 'desc': 'Comma separated list of simple labels starting at #1; use "ignore" to ignore', 'order': 2}}}}}) +param_LogicSelectorBlocks = Parameter({'title': 'Logic Selector blocks', 'schema': {'type': 'array', 'items': {'type': 'object', 'properties': { + 'instance': {'type': 'string', 'desc': 'Instance ID or tag', 'order': 1}, + 'names': {'type': 'string', 'desc': 'Comma separated list of simple labels starting at #1; use "ignore" to ignore', 'order': 2}}}}}) + param_SourceSelectBlocks = Parameter({'title': 'Source-Select blocks', 'schema': {'type': 'array', 'items': {'type': 'object', 'properties': { 'instance': {'type': 'string', 'desc': 'Instance ID or tag', 'order': 3}, - 'sourceCount': {'type': 'integer', 'desc': 'The number of sources being routed', 'order': 4}}}}}) + 'sourceCount': {'type': 'integer', 'desc': 'The number of sources being routed', 'order': 4}, + 'names': {'type': 'string', 'desc': 'Comma separated list of simple labels starting at #1; use "ignore" to ignore', 'order': 5}}}}}) param_MeterBlocks = Parameter({'title': 'Meter blocks', 'schema': {'type': 'array', 'items': {'type': 'object', 'properties': { - 'type': {'type': 'string', 'enum': ['Peak', 'RMS', 'Presence'], 'order': 1}, + 'type': {'type': 'string', 'enum': ['Peak', 'RMS', 'Presence', 'Logic'], 'order': 1}, 'instance': {'type': 'string', 'desc': 'Instance ID or tag', 'order': 2}, 'names': {'type': 'string', 'desc': 'Comma separated list of simple labels starting at #1; use "ignore" to ignore', 'order': 3}}}}}) @@ -45,7 +73,25 @@ 'ignoreCrossPoints': {'type': 'boolean', 'desc': 'Ignore cross-point states to reduce number of controls', 'order': 7} }}}}) +param_ParleMicBlocks = Parameter({'title': u'Parlé Microphone blocks', 'schema': {'type': 'array', 'items': {'type': 'object', 'properties': { + 'instance': { 'type': 'string', 'order': 1, 'desc': 'Instance ID or tag' }, + 'label': { 'type': 'string', 'order': 2 }, + 'names': { 'type': 'string', 'order': 3, 'desc': 'Comma separated list of simple labels' } + }}}}) + +param_Presets = Parameter({ 'schema': { 'type': 'array', 'items': { 'type': 'object', 'properties': { + 'presetID': { 'type': 'integer', 'hint': '(starts at 1000)', 'order': 1 }, + 'label': { 'type': 'string' }}}}}) + +param_Routers = Parameter({'title': 'Router blocks', 'schema': {'type': 'array', 'items': {'type': 'object', 'properties': { + 'label': {'type': 'string', 'order': 1}, + 'instance': {'type': 'string', 'desc': 'Instance ID or tag', 'order': 4}, + 'inputNames': {'type': 'string', 'desc': 'Comma separated list of simple labels', 'order': 5}, + 'outputNames': {'type': 'string', 'desc': 'Comma separated list of simple labels', 'order': 6}}}}}) + #
0: onSuccess(resp[valuePos+8:]) - else: console.warn('no value in resp; was [%s]' % resp) + if valuePos > 0: + onSuccess(resp[valuePos+8:]) + else: + _errorCount += 1 + console.warn('no value in resp; was [%s]' % resp) + +# + + +# INPUTGAIN_SCHEMA = {'type': 'integer', 'desc': '0, 6, 12, 18, 24, 30, 36, 42, 48, 54, 60, 66'} @@ -101,8 +235,8 @@ def bindLevels(): for info in param_LevelBlocks or []: levelInstance = info['instance'] for num, name in enumerate(info['names'].split(',')): - initNumberValue('Level', 'level', name, levelInstance, num+1) - initBoolValue('Level Muting', 'mute', name, levelInstance, num+1) + initNumberValue('Level', 'level', name, levelInstance, num+1, group=levelInstance) + initBoolValue('Level Muting', 'mute', name, levelInstance, num+1, group=levelInstance) @after_main def bindMutes(): @@ -116,6 +250,17 @@ def bindMutes(): else: initBoolValue('Mute', 'mute', 'All', instance, 1) + +@after_main +def bindLogicSelectorBlocks(): + for info in param_LogicSelectorBlocks or []: + instance = info['instance'] + + names = (info['names'] or '').strip() + if len(names) > 0: + for num, name in enumerate([x.strip() for x in names.split(',')]): + initBoolValue('LogicSelect', 'state', name, instance, num+1) + @after_main def bindMatrixMixers(): @@ -168,7 +313,74 @@ def bindStandardMixers(): instance, outputNum+1, group='"%s %s"' % (info['label'], outputName)) - # TODO: also expose input levels + # TODO: also expose input levels + + +@after_main +def processParleMicBlocks(): + for info in param_ParleMicBlocks or []: + instance = info['instance'] + label = info['label'] or instance + + names = (info['names'] or '').strip() + if len(names) > 0: + for num, name in enumerate([x.strip() for x in names.split(',')]): + initBoolValue('Mute', 'mute', name, instance, num+1) + + +@after_main +def bindPresets(): + def preset_handler(presetID): + # e.g. DEVICE recallPreset 1011 + tcp_request('DEVICE recallPreset %s\n' % presetID, lambda resp: parseResp(resp, lambda ignore: e_presetRecall.emit(presetID))) + + if param_Presets: + e_presetRecall = create_local_event('Preset Recall', { 'group': 'Presets', 'order': next_seq(), 'schema': { 'type': 'integer' }}) + a_presetRecall = create_local_action('Preset Recall', preset_handler, { 'group': 'Presets', 'order': next_seq(), 'schema': { 'type': 'integer' }}) + + for info in param_Presets or EMPTY: + presetID = info['presetID'] + label = info['label'] + + initPreset(e_presetRecall, a_presetRecall, presetID, label) + +def initPreset(e_presetRecall, a_presetRecall, presetID, label): + e = create_local_event('Preset %s' % presetID, { 'title': '%s "%s"' % (presetID, label), 'group': 'Presets', 'order': next_seq(), 'schema': { 'type': 'boolean' }}) + e_presetRecall.addEmitHandler(lambda arg: e.emit(arg == presetID)) + + def action_handler(ignore): + console.info('Preset %s "%s" called' % (presetID, label)) + a_presetRecall.call(presetID) + + a = create_local_action('Preset %s' % presetID, action_handler, { 'title': '%s "%s"' % (presetID, label), 'group': 'Presets', 'order': next_seq() }) + +@after_main +def bindRouters(): + for info in param_Routers or []: + instance = info['instance'] + + # Router1 get input 1 +OK "value":0 OR + # Router1 set input 1 1 +OK + + for outputNum, outputName in enumerate(info['outputNames'].split(',')): + outputName = outputName.strip() + initNumberValue('Router', 'input', outputName, instance, outputNum+1, isInteger=True) + # e.g. name ends up as "AreaCombiningRouter 1 Router" + + # discrete selections + for inputNum, inputName in enumerate(info['inputNames'].split(',')): + inputName = inputName.strip() + initRouterPoint(instance, outputNum+1, outputName, inputNum+1, inputName) + +def initRouterPoint(inst, oNum, oName, iNum, iName): # required to avoid variable capture bugs + name = '%s %s %s' % (inst, oNum, iNum) + + e = create_local_event(name, { 'title': '"%s"' % iName, 'group': 'Router %s' % inst, 'order': next_seq(), 'schema': { 'type': 'boolean' }}) + + lookup_local_event('%s %s Router' % (inst, oNum)).addEmitHandler(lambda arg: e.emit(iNum == arg)) + + a = create_local_action(name, lambda ignore: lookup_local_action('%s %s Router' % (inst, oNum)).call(iNum), { 'title': '"%s"' % iName, 'group': 'Router %s' % inst, 'order': next_seq() }) + def initBoolValue(controlType, cmd, label, inst, index1, index2=None, group=None): if index2 == None: @@ -203,7 +415,7 @@ def initBoolValue(controlType, cmd, label, inst, index1, index2=None, group=None lambda result: signal.emit(arg))), # NOTE: uses the original 'arg' here {'title': title, 'group': group, 'order': next_seq(), 'schema': schema}) - Timer(lambda: getter.call(), random(120,150), random(5,10)) + _pollers.append(Timer(lambda: getter.call(), random(120,150), random(5,10), stopped=True)) # and come conveniece derivatives @@ -243,18 +455,19 @@ def initNumberValue(controlType, cmd, label, inst, index1, isInteger=False, inde lambda result: signal.emit(arg))), # NOTE: uses the original 'arg' here {'title': title, 'group': group, 'order': next_seq(), 'schema': schema}) - Timer(lambda: getter.call(), random(120,150), random(5,10)) + _pollers.append(Timer(lambda: getter.call(), random(120,150), random(5,10), stopped=True)) @after_main def bindSourceSelects(): for info in param_SourceSelectBlocks or []: - initSourceSelect(info['instance'], info['sourceCount']) + initSourceSelect(info['instance'], info['sourceCount'], info['names']) + -def initSourceSelect(inst, sourceCount): +def initSourceSelect(inst, sourceCount, names): name = inst title = inst group = inst - + signal = Event(name, {'title': title, 'group': group, 'order': next_seq(), 'schema': {'type': 'integer'}}) getter = Action('Get ' + name, lambda arg: tcp_request('%s get sourceSelection\n' % (inst), @@ -267,14 +480,17 @@ def initSourceSelect(inst, sourceCount): lambda result: signal.emit(int(arg)))), # NOTE: uses the original 'arg' here {'title': title, 'group': group, 'schema': {'type': 'integer'}}) - for i in range(1, sourceCount+1): - bindSourceItem(inst, i, setter, signal) + bindSourceItem(inst, 0, None, setter, signal) - Timer(lambda: getter.call(), random(120,150), random(5,10)) + for i, label in zip(range(1, sourceCount+1), [x.strip() for x in names.split(',')]): + bindSourceItem(inst, i, label, setter, signal) -def bindSourceItem(inst, i, setter, signal): + _pollers.append(Timer(lambda: getter.call(), random(120,150), random(5,10), stopped=True)) + +def bindSourceItem(inst, i, label, setter, signal): name = '%s %s Selected' % (inst, i) - title = 'Source %s' % i + if label: title = '"%s"' % (label) + else: title = 'Source %s' % i group = inst selectedSignal = Event(name, {'title': title, 'group': inst, 'order': next_seq(), 'schema': {'type': 'boolean'}}) @@ -306,6 +522,10 @@ def initMeters(meterType, label, inst, index): cmd = 'present' schema = {'type': 'boolean'} + elif meterType == 'Logic': + cmd = 'state' + schema = {'type': 'boolean'} + else: cmd = 'level' schema = {'type': 'number'} @@ -318,6 +538,9 @@ def handleResult(result): if meterType == 'Presence': signal.emitIfDifferent(result=='true') + elif meterType == 'Logic': + signal.emitIfDifferent(result=='true') + else: signal.emit(float(result)) @@ -326,16 +549,55 @@ def poll(): lambda resp: parseResp(resp, handleResult)) # start meters much later to avoid being overwhelmed with feedback - Timer(poll, 0.2, random(30,45)) + _pollers.append(Timer(poll, 0.5, random(30,45), stopped=True)) # only requests *if ready* def tcp_request(req, onResp): if receivedTelnetOptions: - tcp.request(req, onResp) - + # tcp.request(req, onResp) + queue.request(lambda: tcp.send(req), onResp) + # --- protocol> - +from org.nodel.io import Stream # for reading ObjectIDs.csv +from java.io import File # ditto +import csv + +@after_main +def bindObjectFileExport(): + f = File(_node.getRoot(), 'ObjectIDs.txt') + if not f.exists(): + return + + console.info("import: ObjectIDs.txt exists...") + + header = None + + for row in csv.reader(Stream.readFully(f).splitlines()): + if header is None: + header = dict([(name.lower(), i) for i, name in enumerate(row) ]) + continue + + parseRow(row[header['type']], row[header['label']], row[header['partition name']], row[header['instance tag']]) + +def parseRow(objType, objLabel, objPartName, objTag): + if objType == 'AudioMeter': + # Object Code, Type, Label, Partition Name, Partition ID, Unit, Instance Tag + # AudioMeter24, AudioMeter, RMS Meter, LVL 25 Combining Space, 1, 1, AudioMeter24 + + console.log('import: RMS Meter "%s" - will only use first channel. Override if necessary' % objTag) + initMeters('RMS', 'main', objTag, 1) + + elif objType == 'Level': + # Object Code, Type, Label, Partition Name, Partition ID, Unit, Instance Tag + # Level31, Level, TR1 PS, LVL 25 Combining Space, 1, 1, TR1PSLVL + + initNumberValue('Level', 'level', 'Main', objTag, 1, group=objPartName) + initBoolValue('Level Muting', 'level', 'Main', objTag, 1, group=objPartName) + + else: + console.log('import: %s "%s" - unknown type, skipping' % (objType, objTag)) + # 1024: console.warn('buffer too big; dropped; was "%s"' % ''.join(recvBuffer)) del recvBuffer[:] + global _errorCount + _errorCount += 1 def telnet_frame_received(data): log(2, 'telnet_recv [%s]' % (data.encode('hex'))) @@ -402,11 +669,14 @@ def telnet_frame_received(data): def msg_received(data): log(2, 'msg_recv [%s]' % (data.strip())) - lastReceive[0] = system_clock() + global _lastReceive + _lastReceive = system_clock() if 'Welcome to the Tesira Text Protocol Server...' in data: global receivedTelnetOptions receivedTelnetOptions = True + + [ p.start() for p in _pollers ] def tcp_sent(data): log(3, 'tcp_sent [%s] -- [%s]' % (data, data.encode('hex'))) @@ -417,15 +687,21 @@ def tcp_disconnected(): global receivedTelnetOptions receivedTelnetOptions = False + [ p.stop() for p in _pollers ] + def tcp_timeout(): console.warn('tcp_timeout; dropping (if connected)') + + global _errorCount + _errorCount += 1 + tcp.drop() def protocolTimeout(): console.log('protocol timeout; flushing buffer; dropping connection (if connected)') queue.clearQueue() del recvBuffer[:] - telnetBuffer[:] + del telnetBuffer[:] global receivedTelnetOptions receivedTelnetOptions = False @@ -455,55 +731,84 @@ def log(level, msg): # ---> -# status_check_interval+15: previousContactValue = local_event_LastContactDetect.getArg() if previousContactValue == None: - message = 'Always been missing.' + message = 'Never been monitored' else: - previousContact = date_parse(previousContactValue) - roughDiff = (now.getMillis() - previousContact.getMillis())/1000/60 - if roughDiff < 60: - message = 'Missing for approx. %s mins' % roughDiff - elif roughDiff < (60*24): - message = 'Missing since %s' % previousContact.toString('h:mm:ss a') - else: - message = 'Missing since %s' % previousContact.toString('h:mm:ss a, E d-MMM') + message = 'Unmonitorable %s' % formatPeriod(previousContactValue) local_event_Status.emit({'level': 2, 'message': message}) + return + + local_event_LastContactDetect.emit(str(now)) + + # check general protocol faults + global _lastErrorCount, _lastErrorCountTimestamp + errorCount = _errorCount + errorDiff = errorCount - _lastErrorCount + timeDiff = nowClock - _lastErrorCountTimestamp + + _lastErrorCount, _lastErrorCountTimestamp = errorCount, nowClock + + errorsPerMin = errorDiff * 60000 / timeDiff + + if errorsPerMin > 0: # if more than 10 general errors per minute, show the warning + local_event_Status.emit({ "level": 1, "message": "Protocol is reporting more than %s device faults per minute; will recycle connection after 5 mins." % errorsPerMin }) - else: - # update contact info - local_event_LastContactDetect.emit(str(now)) - - # TODO: check internal device status if possible - - local_event_LastContactDetect.emit(str(now)) - local_event_Status.emit({'level': 0, 'message': 'OK'}) - + global _lastConnectionRecycle + if nowClock - _lastConnectionRecycle > (5 * 60000): + console.error("Has been more than 5 mins with a high fault rate, dropping connection") + tcp.drop() + _lastConnectionRecycle = nowClock + + return + + activeFaults = local_event_ActiveFaults.getArg() + if local_event_ActiveFaults.getArg() != "NONE": + local_event_Status.emit({ "level": 2, "message": "Device(s) report faults and likely need attention - %s, %s" % (activeFaults, formatPeriod(local_event_LastNoFaults.getArg())) }) + return + + local_event_Status.emit({'level': 0, 'message': 'OK'}) + status_check_interval = 75 status_timer = Timer(statusCheck, status_check_interval) +def formatPeriod(dateStr, asInstant=False): + if dateStr == None: return 'for unknown period' + + dateObj = date_parse(dateStr) + + now = date_now() + diff = (now.getMillis() - dateObj.getMillis()) / 1000 / 60 # in mins + + if diff < 0: return 'never ever' + elif diff == 0: return 'for <1 min' if not asInstant else '<1 min ago' + elif diff < 60: return ('for <%s mins' if not asInstant else '<%s mins ago') % diff + elif diff < 60*24: return ('since %s' if not asInstant else 'at %s') % dateObj.toString('h:mm a') + else: return ('since %s' if not asInstant else 'on %s') % dateObj.toString('E d-MMM h:mm a') + # ---> @@ -519,4 +824,4 @@ def getOrDefault(value, default): def random(fromm, to): return fromm + _rand.nextDouble()*(to - fromm) -# ---> \ No newline at end of file +# --->