From b47fd15155f28f22b9ea19336a30e848469bae23 Mon Sep 17 00:00:00 2001 From: Mike Zingman Date: Sun, 10 Jul 2016 09:53:36 -0400 Subject: [PATCH] Merge ambe_audio_2way into master --- ambe_audio.cfg | 60 ++++- ambe_audio.py | 544 ++++++++++++++++++++++++++++++++++------ ambe_audio_commands.txt | 31 +++ dmrlink.py | 74 +++--- template.bin | Bin 0 -> 54622 bytes 5 files changed, 597 insertions(+), 112 deletions(-) create mode 100644 ambe_audio_commands.txt create mode 100644 template.bin diff --git a/ambe_audio.cfg b/ambe_audio.cfg index 626e25c..463b45c 100644 --- a/ambe_audio.cfg +++ b/ambe_audio.cfg @@ -1,9 +1,53 @@ -[SETTINGS] -_debug = False -_outToFile = False -_outToUDP = True -_gateway = 127.0.0.1 -_gateway_port = 1234 -_remote_control_port = 1235 -_tg_filter = 2,3,13,3174,3777215,3100,9,9998,3112 +################################################ +# ambe_audio configuration file. +################################################ + +# DEFAULTS - General settings. These values are +#inherited in each subsequent section (defined by section value). +[DEFAULTS] +debug = False # Debug output for each VOICE frame +outToFile = False # Write each AMBE frame to a file called ambe.bin +outToUDP = True # Send each AMBE frame to the _sock object (turn on/off DMRGateway operation) +gateway = 127.0.0.1 # IP address of DMRGateway app +toGatewayPort = 31000 # Port DMRGateway is listening on for AMBE frames to decode +remoteControlPort = 31002 # Port that ambe_audio is listening on for remote control commands +fromGatewayPort = 31003 # Port to listen on for AMBE frames to transmit to all peers +gatewayDmrId = 0 # id to use when transmitting from the gateway +tgFilter = 9 # A list of TG IDs to monitor. All TGs will be passed to DMRGateway +txTg = 9 # TG to use for all frames received from DMRGateway -> IPSC +txTs = 2 # Slot to use for frames received from DMRGateway -> IPSC +# +# The section setting defines the current section to use. By default, the ‘ENABLED’ section in dmrlink.cfg is used. +# Any values in the named section override the values from the DEFAULTS section. For example, if the BM section +# has a value for gatewayDmrId it would override the value above. Only one section should be set here. Think +# of this as an easy way to switch between several different configurations with a single line. +# +#section = BM # Use BM section values +#section = Sandbox # Use SANDBOX section values + +[BM] # BrandMeister +tgFilter = 3100,31094 # A list of TG IDs to monitor. All TGs will be passed to DMRGateway +txTg = 3100 # TG to use for all frames received from DMRGateway -> IPSC +txTs = 2 # Slot to use for frames received from DMRGateway -> IPSC + +[BM2] # Alternate BM configuration +tgFilter = 31094 +txTg = 31094 +txTs = 2 + +[Sandbox] # DMR MARC sandbox network +tgFilter = 3120 +txTg = 3120 +txTs = 2 + +[Sandbox2] # DMR MARC sandbox network +tgFilter = 1 +txTg = 1 +txTs = 1 + +[N4IRS] # N4IRS/INAD network +tgFilter = 1,2,3,13,3174,3777215,3100,9,9998,3112,3136,310,311,312,9997 +txTg = 9998 +txTs = 2 + diff --git a/ambe_audio.py b/ambe_audio.py index 5c60c30..7dff76f 100755 --- a/ambe_audio.py +++ b/ambe_audio.py @@ -16,10 +16,11 @@ from bitstring import BitArray import sys, socket, ConfigParser, thread, traceback import cPickle as pickle -from dmrlink import IPSC, NETWORK, networks, logger, int_id, hex_str_3, get_info, talkgroup_ids, subscriber_ids, peer_ids, PATH -from time import time +from dmrlink import IPSC, NETWORK, networks, logger, int_id, hex_str_3, hex_str_4, get_info, talkgroup_ids, peer_ids, PATH, get_subscriber_info, reread_subscribers +from time import time, sleep, clock, localtime, strftime import csv - +import struct +from random import randint __author__ = 'Cortney T. Buffington, N0MJS' __copyright__ = 'Copyright (c) 2013 - 2016 Cortney T. Buffington, N0MJS and the K0USY Group' @@ -35,55 +36,84 @@ try: except ImportError: sys.exit('IPSC message types file not found or invalid') +try: + from ipsc.ipsc_mask import * +except ImportError: + sys.exit('IPSC mask values file not found or invalid') + + # # ambeIPSC class, # class ambeIPSC(IPSC): - _configFile='ambe_audio.cfg' - _debug = False - _outToFile = False - _outToUDP = True + _configFile='ambe_audio.cfg' # Name of the config file to over-ride these default values + _debug = False # Debug output for each VOICE frame + _outToFile = False # Write each AMBE frame to a file called ambe.bin + _outToUDP = True # Send each AMBE frame to the _sock object (turn on/off DMRGateway operation) #_gateway = "192.168.1.184" - _gateway = "127.0.0.1" - _gateway_port = 1234 - _remote_control_port = 1235 + _gateway = "127.0.0.1" # IP address of DMRGateway app + _gateway_port = 31000 # Port DMRGateway is listening on for AMBE frames to decode + _remote_control_port = 31002 # Port that ambe_audio is listening on for remote control commands + _ambeRxPort = 31003 # Port to listen on for AMBE frames to transmit to all peers + _gateway_dmr_id = 0 # id to use when transmitting from the gateway _tg_filter = [2,3,13,3174,3777215,3100,9,9998,3112] #set this to the tg to monitor - _no_tg = -99 - _sock = -1; - lastPacketTimeout = 0 - _transmitStartTime = 0 + + _no_tg = -99 # Flag (const) that defines a value for "no tg is currently active" + _busy_slots = [0,0,0] # Keep track of activity on each slot. Make sure app is polite + _sock = -1; # Socket object to send AMBE to DMRGateway + lastPacketTimeout = 0 # Time of last packet. Used to trigger an artifical TERM if one was not seen + _transmitStartTime = 0 # Used for info on transmission duration + _start_seq = 0 # Used to maintain error statistics for a transmission + _packet_count = 0 # Used to maintain error statistics for a transmission + _seq = 0 # Transmit frame sequence number (auto-increments for each frame) + _f = None # File handle for debug AMBE binary output + + _tx_tg = hex_str_3(9998) # Hard code the destination TG. This ensures traffic will not show up on DMR-MARC + _tx_ts = 2 # Time Slot 2 + _currentNetwork = "" + _dmrgui = '' + + ###### DEBUGDEBUGDEBUG + #_d = None + ###### DEBUGDEBUGDEBUG def __init__(self, *args, **kwargs): IPSC.__init__(self, *args, **kwargs) self.CALL_DATA = [] - # # Define default values for operation. These will be overridden by the .cfg file if found # self._currentTG = self._no_tg - self._sequenceNr = 0 - self.readConfigFile(self._configFile) + self._currentNetwork = str(args[0]) + self.readConfigFile(self._configFile, None, self._currentNetwork) - print('DMRLink ambe server') - + logger.info('DMRLink ambe server') + if self._gateway_dmr_id == 0: + sys.exit( "Error: gatewayDmrId must be set (greater than zero)" ) # # Open output sincs # if self._outToFile == True: - f = open('ambe.bin', 'wb') - print('Opening output file: ambe.bin') + self._f = open('ambe.bin', 'wb') + logger.info('Opening output file: ambe.bin') if self._outToUDP == True: self._sock = socket.socket(socket.AF_INET,socket.SOCK_DGRAM) - print('Send UDP frames to DMR gateway {}:{}'.format(self._gateway, self._gateway_port)) - + logger.info('Send UDP frames to DMR gateway {}:{}'.format(self._gateway, self._gateway_port)) + + ###### DEBUGDEBUGDEBUG + #self._d = open('recordData.bin', 'wb') + ###### DEBUGDEBUGDEBUG + try: - thread.start_new_thread( self.remote_control, (self._remote_control_port, ) ) + thread.start_new_thread( self.remote_control, (self._remote_control_port, ) ) # Listen for remote control commands + thread.start_new_thread( self.launchUDP, (args[0], ) ) # Package AMBE into IPSC frames and send to all peers except: traceback.print_exc() - print( "Error: unable to start thread" ) + logger.error( "Error: unable to start thread" ) + # Utility function to convert bytes to string of hex values (for debug) def ByteToHex( self, byteStr ): @@ -92,33 +122,264 @@ class ambeIPSC(IPSC): # # Now read the configuration file and parse out the values we need # - def readConfigFile(self, configFileName): + def defaultOption( self, config, sec, opt, defaultValue ): + try: + _value = config.get(sec, opt).split(None)[0] # Get the value from the named section + except ConfigParser.NoOptionError as e: + try: + _value = config.get('DEFAULTS', opt).split(None)[0] # Try the global DEFAULTS section + except ConfigParser.NoOptionError as e: + _value = defaultValue # Not found anywhere, use the default value + logger.info(opt + ' = ' + str(_value)) + return _value + + def readConfigFile(self, configFileName, sec, networkName='DEFAULTS'): config = ConfigParser.ConfigParser() try: - self._tg_filter=[] config.read(configFileName) - for sec in config.sections(): - for key, val in config.items(sec): - if self._debug == True: - print( '%s="%s"' % (key, val) ) - self._debug = (config.get(sec, '_debug') == "True") - self._outToFile = (config.get(sec, '_outToFile') == "True") - self._outToUDP = (config.get(sec, '_outToUDP') == "True") - self._gateway = config.get(sec, '_gateway') - self._gateway_port = int(config.get(sec, '_gateway_port')) - _tgs = config.get(sec, '_tg_filter') - self._tg_filter = map(int, _tgs.split(',')) + + if sec == None: + sec = self.defaultOption(config, 'DEFAULTS', 'section', networkName) + if config.has_section(sec) == False: + logger.error('Section ' + sec + ' was not found, using DEFAULTS') + sec = 'DEFAULTS' + self._debug = bool(self.defaultOption(config, sec,'debug', self._debug) == 'True') + self._outToFile = bool(self.defaultOption(config, sec,'outToFile', self._outToFile) == 'True') + self._outToUDP = bool(self.defaultOption(config, sec,'outToUDP', self._outToUDP) == 'True') + self._gateway = self.defaultOption(config, sec,'gateway', self._gateway) + self._gateway_port = int(self.defaultOption(config, sec,'toGatewayPort', self._gateway_port)) + + self._remote_control_port = int(self.defaultOption(config, sec,'remoteControlPort', self._remote_control_port)) + self._ambeRxPort = int(self.defaultOption(config, sec,'fromGatewayPort', self._ambeRxPort)) + self._gateway_dmr_id = int(self.defaultOption(config, sec, 'gatewayDmrId', self._gateway_dmr_id)) + + _tgs = self.defaultOption(config, sec,'tgFilter', str(self._tg_filter).strip('[]')) + self._tg_filter = map(int, _tgs.split(',')) + + self._tx_tg = hex_str_3(int(self.defaultOption(config, sec, 'txTg', int_id(self._tx_tg)))) + self._tx_ts = int(self.defaultOption(config, sec, 'txTs', self._tx_ts)) + + except ConfigParser.NoOptionError as e: + print('Using a default value:', e) except: traceback.print_exc() sys.exit('Configuration file \''+configFileName+'\' is not a valid configuration file! Exiting...') + def rewriteFrame( self, _frame, _network, _newSlot, _newGroup, _newSouceID, _newPeerID ): + + _peerid = _frame[1:5] # int32 peer who is sending us a packet + _src_sub = _frame[6:9] # int32 Id of source + _burst_data_type = _frame[30] + + ######################################################################## + # re-Write the peer radio ID to that of this program + _frame = _frame.replace(_peerid, _newPeerID) + # re-Write the source subscriber ID to that of this program + _frame = _frame.replace(_src_sub, _newSouceID) + # Re-Write the destination Group ID + _frame = _frame.replace(_frame[9:12], _newGroup) + + # Re-Write IPSC timeslot value + _call_info = int_id(_frame[17:18]) + if _newSlot == 1: + _call_info &= ~(1 << 5) + elif _newSlot == 2: + _call_info |= 1 << 5 + _call_info = chr(_call_info) + _frame = _frame[:17] + _call_info + _frame[18:] + + _x = struct.pack("i", self._seq) + _frame = _frame[:20] + _x[1] + _x[0] + _frame[22:] + self._seq = self._seq + 1 + + # Re-Write DMR timeslot value + # Determine if the slot is present, so we can translate if need be + if _burst_data_type == BURST_DATA_TYPE['SLOT1_VOICE'] or _burst_data_type == BURST_DATA_TYPE['SLOT2_VOICE']: + # Re-Write timeslot if necessary... + if _newSlot == 1: + _burst_data_type = BURST_DATA_TYPE['SLOT1_VOICE'] + elif _newSlot == 2: + _burst_data_type = BURST_DATA_TYPE['SLOT2_VOICE'] + _frame = _frame[:30] + _burst_data_type + _frame[31:] + + _frame = self.hashed_packet(NETWORK[_network]['LOCAL']['AUTH_KEY'], _frame) + + if (time() - self._busy_slots[_newSlot]) >= 0.10 : # slot is not busy so it is safe to transmit + # Send the packet to all peers in the target IPSC + self.send_to_ipsc(_frame) + else: + logger.info('Slot {} is busy, will not transmit packet from gateway'.format(_newSlot)) + + ######################################################################## + + # Read a record from the captured IPSC file looking for a payload type that matches the filter + def readRecord(self, _file, _match_type): + _notEOF = True + # _file.seek(0) + while (_notEOF): + _data = "" + _bLen = _file.read(4) + if _bLen: + _len, = struct.unpack("i", _bLen) + if _len > 0: + _data = _file.read(_len) + _payload_type = _data[30] + if _payload_type == _match_type: + return _data + else: + _notEOF = False + else: + _notEOF = False + return _data + + # Read bytes from the socket with "timeout" I hate this code. + def readSock( self, _sock, len ): + counter = 0 + while(counter < 3): + _ambe = _sock.recv(len) + if _ambe: break + sleep(0.1) + counter = counter + 1 + return _ambe + + # Concatenate 3 frames from the stream into a bit array and return the bytes + def readAmbeFrameFromUDP( self, _sock ): + _ambeAll = BitArray() # Start with an empty array + for i in range(0, 3): + _ambe = self.readSock(_sock,7) # Read AMBE from the socket + if _ambe: + _ambe1 = BitArray('0x'+h(_ambe[0:49])) + _ambeAll += _ambe1[0:50] # Append the 49 bits to the string + else: + break + return _ambeAll.tobytes() # Return the 49 * 3 as an array of bytes + + # Set up the socket and run the method to gather the AMBE. Sending it to all peers + def launchUDP(self, _network): + s = socket.socket() # Create a socket object + s.bind(('', self._ambeRxPort)) # Bind to the port + + while (1): # Forever! + s.listen(5) # Now wait for client connection. + _sock, addr = s.accept() # Establish connection with client. + if int_id(self._tx_tg) > 0: # Test if we are allowed to transmit + self.playbackFromUDP(_sock, _network) + else: + self.transmitDisabled(_sock, _network) #tg is zero, so just eat the network trafic + _sock.close() + + # This represents a full transmission (HEAD, VOICE and TERM) + def playbackFromUDP(self, _sock, _network): + _delay = 0.055 # Yes, I know it should be 0.06, but there seems to be some latency, so this is a hack + _src_sub = hex_str_3(self._gateway_dmr_id) # DMR ID to sign this transmission with + _src_peer = NETWORK[_network]['LOCAL']['RADIO_ID'] # Use this peers ID as the source repeater + + logger.info('Transmit from gateway to TG {}:'.format(int_id(self._tx_tg)) ) + try: + + try: + _t = open('template.bin', 'rb') # Open the template file. This was recorded OTA + + _tempHead = [0] * 3 # It appears that there 3 frames of HEAD (mostly the same) + for i in range(0, 3): + _tempHead[i] = self.readRecord(_t, BURST_DATA_TYPE['VOICE_HEAD']) + + _tempVoice = [0] * 6 + for i in range(0, 6): # Then there are 6 frames of AMBE. We will just use them in order + _tempVoice[i] = self.readRecord(_t, BURST_DATA_TYPE['SLOT2_VOICE']) + + _tempTerm = self.readRecord(_t, BURST_DATA_TYPE['VOICE_TERM']) + _t.close() + except IOError: + logger.error('Can not open template.bin file') + return + logger.debug('IPSC templates loaded') + + _eof = False + self._seq = randint(0,32767) # A transmission uses a random number to begin its sequence (16 bit) + + for i in range(0, 3): # Output the 3 HEAD frames to our peers + self.rewriteFrame(_tempHead[i], _network, self._tx_ts, self._tx_tg, _src_sub, _src_peer) + #self.group_voice(_network, _src_sub, self._tx_tg, True, '', hex_str_3(0), _tempHead[i]) + sleep(_delay) + + i = 0 # Initialize the VOICE template index + while(_eof == False): + _ambe = self.readAmbeFrameFromUDP(_sock) # Read the 49*3 bit sample from the stream + if _ambe: + i = (i + 1) % 6 # Round robbin with the 6 VOICE templates + _frame = _tempVoice[i][:33] + _ambe + _tempVoice[i][52:] # Insert the 3 49 bit AMBE frames + + self.rewriteFrame(_frame, _network, self._tx_ts, self._tx_tg, _src_sub, _src_peer) + #self.group_voice(_network, _src_sub, self._tx_tg, True, '', hex_str_3(0), _frame) + + sleep(_delay) # Since this comes from a file we have to add delay between IPSC frames + else: + _eof = True # There are no more AMBE frames, so terminate the loop + + self.rewriteFrame(_tempTerm, _network, self._tx_ts, self._tx_tg, _src_sub, _src_peer) + #self.group_voice(_network, _src_sub, self._tx_tg, True, '', hex_str_3(0), _tempTerm) + + except IOError: + logger.error('Can not transmit to peers') + logger.info('Transmit complete') + + def transmitDisabled(self, _sock, _network): + _eof = False + logger.debug('Transmit disabled begin') + while(_eof == False): + if self.readAmbeFrameFromUDP(_sock): + pass + else: + _eof = True # There are no more AMBE frames, so terminate the loop + logger.debug('Transmit disabled end') + + # Debug method used to test the AMBE code. + def playbackFromFile(self, _fileName): + _r = open(_fileName, 'rb') + _eof = False + + host = socket.gethostbyname(socket.gethostname()) # Get local machine name + _sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + _sock.connect((host, self._ambeRxPort)) + + while(_eof == False): + + for i in range(0, 3): + _ambe = _r.read(7) + if _ambe: + _sock.send(_ambe) + else: + _eof = True + sleep(0.055) + logger.info('File playback complete') + + def dumpTemplate(self, _fileName): + _file = open(_fileName, 'rb') + _eof = False + + while(_eof == False): + _data = "" + _bLen = _file.read(4) + if _bLen: + _len, = struct.unpack("i", _bLen) + if _len > 0: + _data = _file.read(_len) + self.dumpIPSCFrame(_data) + else: + _eof = True + logger.info('File dump complete') + #************************************************ # CALLBACK FUNCTIONS FOR USER PACKET TYPES #************************************************ # def group_voice(self, _network, _src_sub, _dst_sub, _ts, _end, _peerid, _data): + + #self.dumpIPSCFrame(_data) + # THIS FUNCTION IS NOT COMPLETE!!!! _payload_type = _data[30:31] # _ambe_frames = _data[33:52] @@ -128,91 +389,222 @@ class ambeIPSC(IPSC): _ambe_frame3 = _ambe_frames[100:149] _tg_id = int_id(_dst_sub) + _ts = 2 if _ts else 1 + + self._busy_slots[_ts] = time() + + ###### DEBUGDEBUGDEBUG +# if _tg_id == 2: +# __iLen = len(_data) +# self._d.write(struct.pack("i", __iLen)) +# self._d.write(_data) +# else: +# self.rewriteFrame(_data, _network, 1, 9) + ###### DEBUGDEBUGDEBUG + + if _tg_id in self._tg_filter: #All TGs _dst_sub = get_info(int_id(_dst_sub), talkgroup_ids) if _payload_type == BURST_DATA_TYPE['VOICE_HEAD']: if self._currentTG == self._no_tg: - _src_sub = get_info(int_id(_src_sub), subscriber_ids) - print('Voice Transmission Start on TS {} and TG {} ({}) from {}'.format("2" if _ts else "1", _dst_sub, _tg_id, _src_sub)) + _src_sub = get_subscriber_info(_src_sub) + logger.info('Voice Transmission Start on TS {} and TG {} ({}) from {}'.format(_ts, _dst_sub, _tg_id, _src_sub)) + self._sock.sendto('reply log2 {} {}'.format(_src_sub, _tg_id), (self._dmrgui, 34003)) + self._currentTG = _tg_id self._transmitStartTime = time() + self._start_seq = int_id(_data[20:22]) + self._packet_count = 0 else: if self._currentTG != _tg_id: if time() > self.lastPacketTimeout: self._currentTG = self._no_tg #looks like we never saw an EOT from the last stream - print('EOT timeout') + logger.warning('EOT timeout') else: - print('Transmission in progress, will not decode stream on TG {}'.format(_tg_id)) + logger.warning('Transmission in progress, will not decode stream on TG {}'.format(_tg_id)) if self._currentTG == _tg_id: if _payload_type == BURST_DATA_TYPE['VOICE_TERM']: - print('Voice Transmission End %.2f seconds' % (time() - self._transmitStartTime)) + _source_packets = ( int_id(_data[20:22]) - self._start_seq ) - 3 # the 3 is because the start and end are not part of the voice but counted in the RTP + if self._packet_count > _source_packets: + self._packet_count = _source_packets + if _source_packets > 0: + _lost_percentage = 100.0 - ((self._packet_count / float(_source_packets)) * 100.0) + else: + _lost_percentage = 0.0 + _duration = (time() - self._transmitStartTime) + logger.info('Voice Transmission End {:.2f} seconds loss rate: {:.2f}% ({}/{})'.format(_duration, _lost_percentage, _source_packets - self._packet_count, _source_packets)) + self._sock.sendto("reply log" + + strftime(" %m/%d/%y %H:%M:%S", localtime(self._transmitStartTime)) + + ' {} {} "{}"'.format(get_subscriber_info(_src_sub), _ts, _dst_sub) + + ' {:.2f}%'.format(_lost_percentage) + + ' {:.2f}s'.format(_duration), (self._dmrgui, 34003)) self._currentTG = self._no_tg if _payload_type == BURST_DATA_TYPE['SLOT1_VOICE']: self.outputFrames(_ambe_frames, _ambe_frame1, _ambe_frame2, _ambe_frame3) + self._packet_count += 1 if _payload_type == BURST_DATA_TYPE['SLOT2_VOICE']: self.outputFrames(_ambe_frames, _ambe_frame1, _ambe_frame2, _ambe_frame3) + self._packet_count += 1 self.lastPacketTimeout = time() + 10 else: if _payload_type == BURST_DATA_TYPE['VOICE_HEAD']: _dst_sub = get_info(int_id(_dst_sub), talkgroup_ids) - print('Ignored Voice Transmission Start on TS {} and TG {}'.format("2" if _ts else "1", _dst_sub)) + logger.warning('Ignored Voice Transmission Start on TS {} and TG {}'.format(_ts, _dst_sub)) def outputFrames(self, _ambe_frames, _ambe_frame1, _ambe_frame2, _ambe_frame3): if self._debug == True: - print(_ambe_frames) - print('Frame 1:', self.ByteToHex(_ambe_frame1.tobytes())) - print('Frame 2:', self.ByteToHex(_ambe_frame2.tobytes())) - print('Frame 3:', self.ByteToHex(_ambe_frame3.tobytes())) + logger.debug(_ambe_frames) + logger.debug('Frame 1:', self.ByteToHex(_ambe_frame1.tobytes())) + logger.debug('Frame 2:', self.ByteToHex(_ambe_frame2.tobytes())) + logger.debug('Frame 3:', self.ByteToHex(_ambe_frame3.tobytes())) if self._outToFile == True: - f.write( _ambe_frame1.tobytes() ) - f.write( _ambe_frame2.tobytes() ) - f.write( _ambe_frame3.tobytes() ) + self._f.write( _ambe_frame1.tobytes() ) + self._f.write( _ambe_frame2.tobytes() ) + self._f.write( _ambe_frame3.tobytes() ) if self._outToUDP == True: self._sock.sendto(_ambe_frame1.tobytes(), (self._gateway, self._gateway_port)) self._sock.sendto(_ambe_frame2.tobytes(), (self._gateway, self._gateway_port)) self._sock.sendto(_ambe_frame3.tobytes(), (self._gateway, self._gateway_port)) - - def reread_subscribers(self): - try: - with open(PATH+'subscriber_ids.csv', 'rU') as subscriber_ids_csv: - subscribers = csv.reader(subscriber_ids_csv, dialect='excel', delimiter=',') - subscriber_ids = {} - for row in subscribers: - subscriber_ids[int(row[0])] = (row[1]) - print('Subscriber file has been updated') - except ImportError: - logger.warning('subscriber_ids.csv not found: Subscriber aliases will not be available') + def private_voice(self, _network, _src_sub, _dst_sub, _ts, _end, _peerid, _data): + print('private voice') +# __iLen = len(_data) +# self._d.write(struct.pack("i", __iLen)) +# self._d.write(_data) # - # Define a function for the thread - # Use netcat to dynamically change the TGs that are forwarded to Allstar - # echo -n "x,y,z" | nc 127.0.0.1 1235 + # Remote control thread + # Use netcat to dynamically change ambe_audio without a restart + # echo -n "tgs=x,y,z" | nc 127.0.0.1 31002 + # echo -n "reread_subscribers" | nc 127.0.0.1 31002 + # echo -n "reread_config" | nc 127.0.0.1 31002 + # echo -n "txTg=##" | nc 127.0.0.1 31002 + # echo -n "txTs=#" | nc 127.0.0.1 31002 + # echo -n "section=XX" | nc 127.0.0.1 31002 # def remote_control(self, port): s = socket.socket() # Create a socket object - host = socket.gethostname() # Get local machine name - s.bind((host, port)) # Bind to the port + s.bind(('', port)) # Bind to the port s.listen(5) # Now wait for client connection. - print('Remote control is listening on:', host, port) + logger.info('Remote control is listening on {}:{}'.format(socket.getfqdn(), port)) + while True: c, addr = s.accept() # Establish connection with client. - print( 'Got connection from', addr ) - tgs = c.recv(1024) - print('Command:"{}"'.format(tgs)) - if tgs: - if tgs == 'reread_subscribers': - self.reread_subscribers() + logger.info( 'Got connection from {}'.format(addr) ) + self._dmrgui = addr[0] + _tmp = c.recv(1024) + _tmp = _tmp.split(None)[0] #first get rid of whitespace + _cmd = _tmp.split('=')[0] + logger.info('Command:"{}"'.format(_cmd)) + if _cmd: + if _cmd == 'reread_subscribers': + reread_subscribers() + elif _cmd == 'reread_config': + self.readConfigFile(self._configFile, None, self._currentNetwork) + elif _cmd == 'txTg': + self._tx_tg = hex_str_3(int(_tmp.split('=')[1])) + print('New txTg = ' + str(int_id(self._tx_tg))) + elif _cmd == 'txTs': + self._tx_ts = int(_tmp.split('=')[1]) + print('New txTs = ' + str(self._tx_ts)) + elif _cmd == 'section': + self.readConfigFile(self._configFile, _tmp.split('=')[1]) + elif _cmd == 'gateway_dmr_id': + self._gateway_dmr_id = int(_tmp.split('=')[1]) + print('New gateway_dmr_id = ' + str(self._gateway_dmr_id)) + elif _cmd == 'gateway_peer_id': + peerID = int(_tmp.split('=')[1]) + NETWORK[_network]['LOCAL']['RADIO_ID'] = hex_str_3(peerID) + print('New peer_id = ' + str(peerID)) + elif _cmd == 'restart': + reactor.callFromThread(reactor.stop) + elif _cmd == 'playbackFromFile': + self.playbackFromFile('ambe.bin') + elif _cmd == 'tgs': + _args = _tmp.split('=')[1] + self._tg_filter = map(int, _args.split(',')) + logger.info( 'New TGs={}'.format(self._tg_filter) ) + elif _cmd == 'dump_template': + self.dumpTemplate('PrivateVoice.bin') + elif _cmd == 'get_info': + self._sock.sendto('reply dmr_info {} {} {} {}'.format(self._currentNetwork, + int_id(NETWORK[self._currentNetwork]['LOCAL']['RADIO_ID']), + self._gateway_dmr_id, + get_subscriber_info(hex_str_3(self._gateway_dmr_id))), (self._dmrgui, 34003)) + elif _cmd == 'eval': + _sz = len(_tmp)-5 + _evalExpression = _tmp[-_sz:] + _evalResult = eval(_evalExpression) + print("eval of {} is {}".format(_evalExpression, _evalResult)) + self._sock.sendto('reply eval {}'.format(_evalResult), (self._dmrgui, 34003)) + elif _cmd == 'exec': + _sz = len(_tmp)-5 + _evalExpression = _tmp[-_sz:] + exec(_evalExpression) + print("exec of {}".format(_evalExpression)) else: - self._tg_filter = map(int, tgs.split(',')) - print( 'New TGs=', self._tg_filter ) + logger.error('Unknown command') c.close() # Close the connection + #************************************************ + # Debug: print IPSC frame on console + #************************************************ + def dumpIPSCFrame( self, _frame ): + + _packettype = int_id(_frame[0:1]) # int8 GROUP_VOICE, PVT_VOICE, GROUP_DATA, PVT_DATA, CALL_MON_STATUS, CALL_MON_RPT, CALL_MON_NACK, XCMP_XNL, RPT_WAKE_UP, DE_REG_REQ + _peerid = int_id(_frame[1:5]) # int32 peer who is sending us a packet + _ipsc_seq = int_id(_frame[5:6]) # int8 looks like a sequence number for a packet + _src_sub = int_id(_frame[6:9]) # int32 Id of source + _dst_sub = int_id(_frame[9:12]) # int32 Id of destination + _call_type = int_id(_frame[12:13]) # int8 Priority Voice/Data + _call_ctrl_info = int_id(_frame[13:17]) # int32 + _call_info = int_id(_frame[17:18]) # int8 Bits 6 and 7 defined as TS and END + + # parse out the RTP values + _rtp_byte_1 = int_id(_frame[18:19]) # Call Ctrl Src + _rtp_byte_2 = int_id(_frame[19:20]) # Type + _rtp_seq = int_id(_frame[20:22]) # Call Seq No + _rtp_tmstmp = int_id(_frame[22:26]) # Timestamp + _rtp_ssid = int_id(_frame[26:30]) # Sync Src Id + + _payload_type = _frame[30] # int8 VOICE_HEAD, VOICE_TERM, SLOT1_VOICE, SLOT2_VOICE + + _ts = bool(_call_info & TS_CALL_MSK) + _end = bool(_call_info & END_MSK) + + if _payload_type == BURST_DATA_TYPE['VOICE_HEAD']: + print('HEAD:', h(_frame)) + if _payload_type == BURST_DATA_TYPE['VOICE_TERM']: + + _ipsc_rssi_threshold_and_parity = int_id(_frame[31]) + _ipsc_length_to_follow = int_id(_frame[32:34]) + _ipsc_rssi_status = int_id(_frame[34]) + _ipsc_slot_type_sync = int_id(_frame[35]) + _ipsc_data_size = int_id(_frame[36:38]) + _ipsc_data = _frame[38:38+(_ipsc_length_to_follow * 2)-4] + _ipsc_full_lc_byte1 = int_id(_frame[38]) + _ipsc_full_lc_fid = int_id(_frame[39]) + _ipsc_voice_pdu_service_options = int_id(_frame[40]) + _ipsc_voice_pdu_dst = int_id(_frame[41:44]) + _ipsc_voice_pdu_src = int_id(_frame[44:47]) + + print('{} {} {} {} {} {} {} {} {} {} {}'.format(_ipsc_rssi_threshold_and_parity,_ipsc_length_to_follow,_ipsc_rssi_status,_ipsc_slot_type_sync,_ipsc_data_size,h(_ipsc_data),_ipsc_full_lc_byte1,_ipsc_full_lc_fid,_ipsc_voice_pdu_service_options,_ipsc_voice_pdu_dst,_ipsc_voice_pdu_src)) + print('TERM:', h(_frame)) + if _payload_type == BURST_DATA_TYPE['SLOT1_VOICE']: + _rtp_len = _frame[31:32] + _ambe = _frame[33:52] + print('SLOT1:', h(_frame)) + if _payload_type == BURST_DATA_TYPE['SLOT2_VOICE']: + _rtp_len = _frame[31:32] + _ambe = _frame[33:52] + print('SLOT2:', h(_frame)) + print("pt={:02X} pid={} seq={:02X} src={} dst={} ct={:02X} uk={} ci={} rsq={}".format(_packettype, _peerid,_ipsc_seq, _src_sub,_dst_sub,_call_type,_call_ctrl_info,_call_info,_rtp_seq)) + if __name__ == '__main__': logger.info('DMRlink \'ambe_audio.py\' (c) 2015 N0MJS & the K0USY Group - SYSTEM STARTING...') for ipsc_network in NETWORK: diff --git a/ambe_audio_commands.txt b/ambe_audio_commands.txt new file mode 100644 index 0000000..55e698e --- /dev/null +++ b/ambe_audio_commands.txt @@ -0,0 +1,31 @@ +AllStar DTMF command examples: +82=cmd,/bin/bash -c 'do something here' +82=cmd,/bin/bash -c 'echo -n "section=Shutup" | nc 127.0.0.1 31002' + +Shell command examples: +# Use netcat to dynamically change ambe_audio without a restart +# echo -n "tgs=x,y,z" | nc 127.0.0.1 31002 +# echo -n "reread_subscribers" | nc 127.0.0.1 31002 +# echo -n "reread_config" | nc 127.0.0.1 31002 +# echo -n "txTg=##" | nc 127.0.0.1 31002 +# echo -n "txTs=#" | nc 127.0.0.1 31002 +# echo -n "section=XX" | nc 127.0.0.1 31002 + +Remote control commands: +'reread_subscribers' +'reread_config' +'txTg' +'txTs' +'section' +'gateway_dmr_id' +'gateway_peer_id' +'restart' +'playbackFromFile' +'tgs' +'dump_template' +'get_info' + + + + + diff --git a/dmrlink.py b/dmrlink.py index 7522d4e..d25534e 100755 --- a/dmrlink.py +++ b/dmrlink.py @@ -289,30 +289,47 @@ subscriber_ids = {} peer_ids = {} talkgroup_ids = {} -try: - with open(PATH+'subscriber_ids.csv', 'rU') as subscriber_ids_csv: - subscribers = csv.reader(subscriber_ids_csv, dialect='excel', delimiter=',') - for row in subscribers: - subscriber_ids[int(row[0])] = (row[1]) -except ImportError: - logger.warning('subscriber_ids.csv not found: Subscriber aliases will not be available') +def reread_peers(): + global peer_ids + try: + with open(PATH+'peer_ids.csv', 'rU') as peer_ids_csv: + peers = csv.reader(peer_ids_csv, dialect='excel', delimiter=',') + peer_ids = {} + for row in peers: + peer_ids[int(row[0])] = (row[1]) + except ImportError: + logger.warning('peer_ids.csv not found: Peer aliases will not be available') -try: - with open(PATH+'peer_ids.csv', 'rU') as peer_ids_csv: - peers = csv.reader(peer_ids_csv, dialect='excel', delimiter=',') - for row in peers: - peer_ids[int(row[0])] = (row[1]) -except ImportError: - logger.warning('peer_ids.csv not found: Peer aliases will not be available') +def reread_talkgroups(): + global talkgroup_ids + try: + with open(PATH+'talkgroup_ids.csv', 'rU') as talkgroup_ids_csv: + talkgroups = csv.reader(talkgroup_ids_csv, dialect='excel', delimiter=',') + talkgroup_ids = {} + for row in talkgroups: + talkgroup_ids[int(row[1])] = (row[0]) + except ImportError: + logger.warning('talkgroup_ids.csv not found: Talkgroup aliases will not be available') -try: - with open(PATH+'talkgroup_ids.csv', 'rU') as talkgroup_ids_csv: - talkgroups = csv.reader(talkgroup_ids_csv, dialect='excel', delimiter=',') - for row in talkgroups: - talkgroup_ids[int(row[1])] = (row[0]) -except ImportError: - logger.warning('talkgroup_ids.csv not found: Talkgroup aliases will not be available') +def reread_subscribers(): + global subscriber_ids + try: + with open(PATH+'subscriber_ids.csv', 'rU') as subscriber_ids_csv: + subscribers = csv.reader(subscriber_ids_csv, dialect='excel', delimiter=',') + subscriber_ids = {} + for row in subscribers: + subscriber_ids[int(row[0])] = (row[1]) + print('Subscriber file has been updated.', len(subscriber_ids), 'IDs imported') + except ImportError: + logger.warning('subscriber_ids.csv not found: Subscriber aliases will not be available') + +reread_peers() +reread_talkgroups() +reread_subscribers() + +def get_subscriber_info(_src_sub): + return get_info(int_id(_src_sub), subscriber_ids) #************************************************ # UTILITY FUNCTIONS FOR INTERNAL USE @@ -449,8 +466,8 @@ def process_flags_bytes(_hex_flags): 'VOICE': _voice, 'MASTER': _master } - - + + # Take a received peer list and the network it belongs to, process and populate the # data structure in my_ipsc_config with the results, and return a simple list of peers. # @@ -601,7 +618,7 @@ if REPORTS['REPORT_NETWORKS'] == 'PICKLE': file.close() except IOError as detail: logger.error('I/O Error: %s', detail) - + elif REPORTS['REPORT_NETWORKS'] == 'PRINT': def reporting_loop(): logger.debug('Periodic Reporting Loop Started (PRINT)') @@ -813,7 +830,7 @@ class IPSC(DatagramProtocol): if _peerid in self._peers.keys(): self._peers[_peerid]['STATUS']['CONNECTED'] = True logger.info('(%s) Registration Reply From: %s, %s:%s', self._network, int_id(_peerid), self._peers[_peerid]['IP'], self._peers[_peerid]['PORT']) - + # OUR MASTER HAS ANSWERED OUR KEEP-ALIVE REQUEST - KEEP TRACK OF IT def master_alive_reply(self, _peerid): self.reset_keep_alive(_peerid) @@ -1117,14 +1134,15 @@ class IPSC(DatagramProtocol): _packettype = data[0:1] _peerid = data[1:5] _ipsc_seq = data[5:6] - + # AUTHENTICATE THE PACKET if not self.validate_auth(self._local['AUTH_KEY'], data): logger.warning('(%s) AuthError: IPSC packet failed authentication. Type %s: Peer: %s, %s:%s', self._network, h(_packettype), int_id(_peerid), host, port) - return + # return # REMOVE SHA-1 AUTHENTICATION HASH: WE NO LONGER NEED IT - data = self.strip_hash(data) + else: + data = self.strip_hash(data) # PACKETS THAT WE RECEIVE FROM ANY VALID PEER OR VALID MASTER if _packettype in ANY_PEER_REQUIRED: diff --git a/template.bin b/template.bin new file mode 100644 index 0000000000000000000000000000000000000000..89bc05c6a2d14492aa5b2f1b3954cd0043ddbe4e GIT binary patch literal 54622 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 literal 0 HcmV?d00001