Files
qt5-legatus-legacy/Lts/Tools/cnr_client.py
2026-08-13 18:33:10 +02:00

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())