#! /usr/bin/env python3 # # Dieses Tool kann die Websocket-Schnittstelle vom svcCombatNetRadio bedienen. # Nach Verbindungsaufbau und mit jedem update-Event vom Server wird getEssentials aufgerufen. # Das Intervall für Positions-Updates kann auf der Kommandozeile angegeben werden. # # Für Hilfe: # $ cnr_client.py --help # # @author Christian Fröbel # from argparse import ArgumentParser, RawDescriptionHelpFormatter from websocket import WebSocketApp, ABNF from uuid import uuid1 import json from queue import Queue from threading import Thread from contextlib import contextmanager import math import mgrs import sys LIGHT_GREEN = '\x1b[32;1m' LIGHT_PURPLE = '\x1b[35;1m' RESET = '\x1b[0m' IN_MARKER = '{}<=={}'.format(LIGHT_PURPLE, RESET) OUT_MARKER = '{}==>{}'.format(LIGHT_GREEN, RESET) # # So eine Klasse kapselt einfach nur den Zugriff auf ein Dictionary, dessen Keys # Strings sind, die außerdem valide Identifier darstellen: die Keys werden Attribute der GeneralConfig-Instanz. # @code # config = DictAccess({'HOME': '/path/to/home', 'USER': 'froebi'}) # config.HOME # => '/path/to/name' # @endcode # class DictAccess(object): # # @param definedVariables: Ein Dictionary mit den Variablennamen als Keys und deren Wert als Values. # def __init__(self, definedVariables): self._variables = definedVariables def __getattribute__(self, name): if name == '_variables': return object.__getattribute__(self, name) try: return self._variables[name] except KeyError: return object.__getattribute__(self, name) def dictAccessIter(iterable): return (DictAccess(e) for e in iterable) # # Verbindet sich mit dem WebSocket-Server, liest die vom Server empfangenen Messages aus und # schreibt sie in die messageQueue für die Verarbeitung. # Dient auch als Sender. # class WsClient(object): def __init__(self, host, port, messageQueue, doLog): self._doLog = doLog self._messageQueue = messageQueue self._ws = WebSocketApp('ws://{}:{}'.format(host, port), on_open=self._onOpen, on_close=self._onClose, on_data=self._onData, on_error=self._onError) def run(self): self._ws.run_forever(ping_timeout=1) def close(self): # Komischerweise dauert das Schließen des WebSockets manchmal etwas länger. # Intern wird noch ein Close-Frame geschickt und auf die Antwort gewartet. # Wie lange gewartet wird, sollte man über den Timeout steuern können; # das scheint aber nicht zuverlässig zu funktionieren. # Wie es aussieht, hängt er im select() (websocket/_app.py), wo ein Timeout von 10 angegeben ist. # Der Timeout im select() ist der als ping_timeout übergebene Parameter von run_forever(). # # Das Problem scheint vielmehr darin begründet zu sein, dass wir das close() aus einem anderen # Thread heraus machen als dem, in dem run_forever() läuft. # Intern wird bei close() tatsächlich auch der Socket gelesen; der eine Thread hängt also im select(), # der andere macht das recv(). self._ws.close(timeout=1) # # @param commandType: z.B. 'mount' # @param data: das data-Property des Commands JSON-kompatibler Typ -- also z.B dict oder None. # @return Die UUID, die für das Command vergeben wurde. # def sendCommand(self, commandType, data): uuid = uuid1() command = {'type': commandType, 'uuid': str(uuid), 'data': data} if self._doLog: print('{} {}: {} '.format(OUT_MARKER, command['type'], json.dumps(command))) self._ws.send(json.dumps(command), ABNF.OPCODE_TEXT) return uuid def _onOpen(self, ws): # # Achtung: spezielle interne Message, die dazu verwendet wird, dem # Consumer zu sagen, dass wir verbunden sind. # self._messageQueue.put('connected') def _onClose(self, ws): pass # # Wenn opcode == TEXT, dann ist data ein Unicode-String, # sonst ist data ein Byte-String. # def _onData(self, ws, data, opcode, fin): # print('_onData: opcode: {}, data: {}, fin: {}'.format(ABNF.OPCODE_MAP[opcode], data, fin)) # Workaround für lange Messages, die in mehr als einem Frame gesendet werden: # Wir bekommen hier offensichtlich schon die zusammengesetzten Daten, aber der opcode ist falsch: der ist dann OPCODE_CONT. # fin ist also immer True. Das Problem ist, dass man eben nicht mehr erkennen kann, ob es sich um Text oder Binary Data handelt. # Wir gehen hier jetzt einfach davon aus, dass es immer Text ist. assert fin, 'currently only atomic messages are supported' assert opcode in (ABNF.OPCODE_TEXT, ABNF.OPCODE_CONT), 'currently only text messages supported' jsonObject = json.loads(data) assert isinstance(jsonObject, dict), 'data must be a dictionary' messageType = jsonObject['type'] if self._doLog: print('{} {}: {}'.format(IN_MARKER, messageType, data)) self._messageQueue.put(jsonObject) def _onError(self, ws, exception): #traceback.print_exception(exception.__class__, exception, exception.__traceback__, sys.stdout) print('{}: {}'.format(exception.__class__.__name__, exception)) # # Eine kleine Hilfsklasse, die auf einfache Weise hilft, zu erkennen, # wann der Initialisierungsprozess abgeschlossen ist. # Nach getEssentials bekommen wir (u.a.) die AssetTypes. # Für jeden AssetType fragen wir alle Assets an (getAssets). # Wenn wir zu jeder Anfrage die Antworte gesehen haben, # sind wir fertig. # class InitHelper(object): def __init__(self): self._pendingRequestCount = 0 @property def pendingCount(self): return self._pendingRequestCount # Muss man rufen, wenn man eine neue Anfrage rausgeschickt hat. def push(self): self._pendingRequestCount += 1 # Muss man rufen, wenn man eine Antwort bekommen hat. # Liefert True, wenn fertig. def pop(self): self._pendingRequestCount -= 1 return self._pendingRequestCount == 0 class CnrClient(object): def __init__(self, posUpdateInterval, messageQueue, sender): self._posUpdateInterval = posUpdateInterval self._sender = sender self._messageQueue = messageQueue self._initHelper = InitHelper() self._finished = False def run(self): self._waitForConnected() while not self._finished: self._processMessage() # # Die erste Message in der Queue ist ein internes Command -- einfach nur ein String. # Das ist für uns das Signal, dass wir mit dem Server verbunden sind und # die Initialisierung via getEssentials starten können. # def _waitForConnected(self): internalCommand = self._messageQueue.get() assert internalCommand == 'connected' self._getEssentials() def _processMessage(self): jsonObject = self._messageQueue.get() messageType = jsonObject['type'] if messageType == 'trainingArea': self._handleTrainingArea(jsonObject) elif messageType == 'radioStation': self._handleRadioStation(jsonObject) elif messageType == 'configCNR': self._handleConfigCnr(jsonObject) elif messageType == 'positionsCNR': self._handlePositionsCnr(jsonObject) elif messageType == 'update': self._handleUpdate(jsonObject) elif messageType == 'error': print('Service responded with error: {}'.format(jsonObject['data']['errorMessage'])) else: assert False, 'unknown/unhandled messageType: {}'.format(messageType) def _handleTrainingArea(self, jsonObject): trainingArea = jsonObject['data']['designation'] print('Training Area: {}'.format(trainingArea)) def _handleRadioStation(self, jsonObject): radioStation = DictAccess(jsonObject['data']) print('Radio Station: {} (id: {})'.format(radioStation.designation, radioStation.id)) def _handleConfigCnr(self, jsonObject): self._getPositionsCnr(self._posUpdateInterval) circuits = jsonObject['data']['radioCircuit'] circuits = sorted(circuits, key=lambda c: c['rcId']) maxNameLen = 0 for circuit in dictAccessIter(circuits): l = len(circuit.rcName) if l > maxNameLen: maxNameLen = l formatString = ' {{}} - {{:{}s}} ({{}})'.format(maxNameLen) print('Radio Circuits:') for circuit in dictAccessIter(circuits): for subcircuit in = dictAccess(circuits) station = subcircuit.station if station == 0: station = 'roaming' print(formatString.format(circuit.rcId, circuit.rcName, station)) def _handlePositionsCnr(self, jsonObject): circuits = jsonObject['data']['radioCircuit'] circuits = sorted(circuits, key=lambda c: c['rcId']) for circuit in dictAccessIter(circuits): rcId = circuit.rcId positions = circuit.positions print('Positions for {}:'.format(rcId)) for pos in dictAccessIter(positions): print(' {}'.format(_toMgrs(pos.lat, pos.lon))) def _handleUpdate(self, jsonObject): self._getEssentials() # # getEssentials ist der Einstiegspunkt. # Muss zu nach Herstellung der Verbindung zu Server gesenedet werden und nach jedem update-Event vom Server. # Der Server mit mehreren Messages in dieser Reihenfolge: # - trainingArea # - radioStation (n-mal) # - configCnr # def _getEssentials(self): self._sender.sendCommand('getEssentials', None) def _getPositionsCnr(self, updateInterval): data = {'repeat': updateInterval} uuid = self._sender.sendCommand('getPositionsCNR', data) # # Kleine Hilfsklasse, die einfach nur auf die Ok- oder Error-Reponse # wartet, die z.B. auf ein mount-Command gesendet wird. # class ResponseWaiter(object): # # @param uuid: die UUID des Commands, auf dessen Response wir warten. # def __init__(self, messageQueue, uuid): self._messageQueue = messageQueue self._uuid = uuid def wait(self): while True: jsonObject = self._messageQueue.get() messageType = jsonObject['type'] data = DictAccess(jsonObject['data']) if data.uuid != str(self._uuid): continue if messageType == 'ok': return True, data.info if messageType == 'error': return False, data.errorMessage assert False, 'Response must be either ok or error' # # Konvertiert (lat, lon) gegeben in Degrees nach MGRS (als String) # def _toMgrs(lat, lon): m = mgrs.MGRS() pos = m.toMGRS(lat, lon, inDegrees=True) pos = pos.decode('ascii') return '{} {} {} {}'.format(pos[:-12], pos[-12:-10], pos[-10:-5], pos[-5:]) @contextmanager def autoClose(client): try: yield client finally: client.close() @contextmanager def autoJoin(thread): try: yield thread finally: thread.join() def _parseCmdLine(): description = 'WebSocket-Client für das Interface Truppensprechfunk (GUEZRG50_ICD-0024_TDS_Anb-TrSpFu).' parser = ArgumentParser(description=description, formatter_class=RawDescriptionHelpFormatter) parser.add_argument('--host', default='localhost', help='hostname/ip; default: localhost') parser.add_argument('--port', '-p', type=int, default=2081, help='CnrProxy port; default: 2081') parser.add_argument('--pos-update-interval', '-u', type=int, metavar='INTERVAL', default=0, dest='posUpdateInterval', help='Update interval for position updates in s; use 0 to disable; default: 0') parser.add_argument('--log-messages', '-l', action='store_true', default=False, dest='doLog', help='log ingoing and outgoing messages') return parser.parse_args() def _main(): args = _parseCmdLine() messageQueue = Queue() client = WsClient(args.host, args.port, messageQueue, args.doLog) initializer = CnrClient(args.posUpdateInterval, messageQueue, client) clientThread = Thread(target=client.run) clientThread.start() with autoJoin(clientThread): with autoClose(client): initializer.run() # beendet sich, wenn initialisiert if __name__ == '__main__': sys.exit(_main())