341 lines
12 KiB
Python
Executable File
341 lines
12 KiB
Python
Executable File
#! /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())
|