|
# Convert a CHIRP CSV file and an AT779UV template settings file into a new AT779UV CPS data file.
|
|
|
|
# Free for non-commercial use.
|
|
# 01/10/25 CFD Initial release.
|
|
|
|
import sys, os, struct, csv, decimal
|
|
|
|
ctcss = ['62.5','67.0','69.3','71.9','74.4','77.0','79.7','82.5','85.4','88.5','91.5',
|
|
'94.8','97.4','100.0','103.5','107.2','110.9','114.8','118.8','123.0','127.3','131.8',
|
|
'136.5','141.3','146.2','151.4','156.7','159.8','162.2','165.5','167.9','171.3','173.8',
|
|
'177.3','179.9','183.5','186.2','189.9','192.8','196.6','199.5','203.5','206.5','210.7',
|
|
'218.1','225.7','229.1','233.6','241.8','250.3','254.1']
|
|
cDuplex = ['','-','+','split','off']
|
|
cMode = ['FM','NFM']
|
|
cStep = ['2.50','5.00','6.25','10.00','12.50','20.00','25.00','30.00','50.00']
|
|
cWatts = ['5.0W','10W','20W']
|
|
cPower = ['L','M','H']
|
|
|
|
def translate(D) :
|
|
B = bytearray(119)
|
|
rFreq = D['Frequency'].partition('.')
|
|
tOffset = D['Offset'].partition('.')
|
|
# Convert a split frequency to an offset.
|
|
rFreqVal = decimal.Decimal(D['Frequency'])
|
|
tOffsetVal = decimal.Decimal(D['Offset'])
|
|
tDuplex = cDuplex.index(D['Duplex'])
|
|
if 3 == tDuplex :
|
|
if tOffsetVal < rFreqVal : # negative offset
|
|
tDuplex = 1
|
|
tOffsetVal = rFreqVal - tOffsetVal
|
|
tOffset = str(tOffsetVal).partition('.')
|
|
else : # positive offset
|
|
tDuplex = 2
|
|
tOffsetVal = tOffsetVal - rFreqVal
|
|
tOffset = str(tOffsetVal).partition('.')
|
|
elif tDuplex > 2 : tDuplex = 0 # convert 'off' and invalid values
|
|
# Retrieve power and special feature configurations from the comment field.
|
|
cComment = D['Comment'].split(' ')
|
|
tPower = 2 # default transmit power to high
|
|
cTXO = False
|
|
cREV = False
|
|
cTA = False
|
|
cCPD = False
|
|
cNC = False
|
|
cBCLO = 0
|
|
for feat in cComment :
|
|
match feat :
|
|
case 'L' | 'M' | 'H' :
|
|
tPower = cPower.index(feat)
|
|
case 'TXO' :
|
|
cTXO = True
|
|
case 'REV' :
|
|
cREV = True
|
|
case 'TA' :
|
|
cTA = True
|
|
case 'CPD' :
|
|
cCPD = True
|
|
case 'NC' :
|
|
cNC = True
|
|
case 'BCLO=R' :
|
|
cBCLO = 1
|
|
case 'BCLO=B' :
|
|
cBCLO = 2
|
|
if 'Power' in D and D['Power'] in cWatts : tPower = cWatts.index(D['Power'])
|
|
# Decode CTCSS/DCS state.
|
|
tMode = b'F' # default off states
|
|
tCode = b'FFF'
|
|
rMode = b'F'
|
|
rCode = b'FFF'
|
|
tCTCSS = False
|
|
tDCS = False
|
|
rCTCSS = False
|
|
rDCS = False
|
|
rSquelch = False
|
|
match D['Tone'] :
|
|
case 'Tone' :
|
|
tCTCSS = True
|
|
case 'TSQL' :
|
|
tCTCSS = True
|
|
rCTCSS = True
|
|
case 'TSQL-R' :
|
|
rCTCSS = True
|
|
case 'DTCS' :
|
|
tDCS = True
|
|
rDCS = True
|
|
case 'DTCS-R':
|
|
rDCS = True
|
|
case 'Cross' :
|
|
if 'CrossMode' in D :
|
|
match D['CrossMode'] :
|
|
case 'Tone->' :
|
|
tCTCSS = True
|
|
case 'Tone->Tone' :
|
|
tCTCSS = True
|
|
rCTCSS = True
|
|
case '->Tone' :
|
|
rCTCSS = True
|
|
case 'Tone->DTCS' :
|
|
tCTCSS = True
|
|
rDCS = True
|
|
case 'DTCS->' :
|
|
tDCS = True
|
|
case 'DTCS->DTCS' :
|
|
tDCS = True
|
|
rDCS = True
|
|
case 'DTCS->Tone' :
|
|
tDCS = True
|
|
rCTCSS = True
|
|
case '->DTCS' :
|
|
rDCS = True
|
|
elif not tCTCSS and not rCTCSS :
|
|
print("Lost \'Cross\' mode configuration in Memory Channel", D['Location'])
|
|
if tCTCSS :
|
|
tMode = b'1'
|
|
tCode = bytes(format(ctcss.index(D['rToneFreq']), '03X'), 'utf-8')
|
|
elif tDCS :
|
|
if D['DtcsPolarity'][0] == 'N' : tMode = b'2'
|
|
else : tMode = b'3'
|
|
tCode = bytes(format(int(D['DtcsCode'], 8), '03X'), 'utf-8')
|
|
if rCTCSS :
|
|
rMode = b'1'
|
|
rCode = bytes(format(ctcss.index(D['cToneFreq']), '03X'), 'utf-8')
|
|
rSquelch = True
|
|
elif rDCS :
|
|
if D['DtcsPolarity'][1] == 'N' : rMode = b'2'
|
|
else : rMode = b'3'
|
|
if 'RxDtcsCode' in D : rCode = bytes(format(int(D['RxDtcsCode'], 8), '03X'), 'utf-8')
|
|
else : rCode = bytes(format(int(D['DtcsCode'], 8), '03X'), 'utf-8')
|
|
rSquelch = True
|
|
struct.pack_into("<3H9s9s8s4H6x3H4xH10xc3sc3s12xH2xH10xH13x", B, 0,
|
|
int(D['Location']), # memory location number
|
|
1, # has to be set to this
|
|
D['Skip'] == 'S', # scan skip flag
|
|
bytes(format(rFreq[0],'0>4'),'utf-8')+bytes(format(rFreq[2],'0<5'),'utf-8'), # receive frequency
|
|
bytes(format(tOffset[0],'0>4'),'utf-8')+bytes(format(tOffset[2],'0<5'),'utf-8'), # transmit offset
|
|
bytes(D['Name'],'utf-8'), # channel name (the radio will only display space, 0-9, A-Z and a-z)
|
|
cStep.index(D['TStep']), # step size
|
|
cTXO, # transmit off flag
|
|
cREV, # reverse flag
|
|
cMode.index(D['Mode']), # channel spacing (bandwidth)
|
|
tDuplex, # offset direction
|
|
tPower, # transmit power
|
|
cTA, # talk around flag
|
|
cCPD, # compander flag
|
|
tMode, tCode, # transmit encode CTCSS/DCS
|
|
rMode, rCode, # receive decode CTCSS/DCS
|
|
# skip optional signaling modes, PTT ID and scrambler settings
|
|
rSquelch, # squelch mode (set to enable CTCSS/DCS decoding)
|
|
cNC, # noise cancelling flag
|
|
# skip DTMF memory
|
|
cBCLO, # busy channel lockout flag
|
|
# skip scramble frequency
|
|
)
|
|
return B
|
|
|
|
if len(sys.argv) <= 2 : sys.exit("usage: <settings filename> <csv filename> [<upper display line>,<lower display line>]")
|
|
sName = sys.argv[1]+".dat"
|
|
cName = sys.argv[2]+".csv"
|
|
dName = sys.argv[2]+".dat"
|
|
if False == os.path.isfile(sName) : sys.exit("Settings file not found.")
|
|
if False == os.path.isfile(cName) : sys.exit("CSV file not found.")
|
|
if True == os.path.isfile(dName) : dName = sys.argv[2]+"_New.dat"
|
|
display = ('','','')
|
|
if len(sys.argv) > 2 : display = sys.argv[3].partition(',')
|
|
# Load settings file into a buffer.
|
|
with open(sName, 'rb', buffering=0) as S :
|
|
settingsBuf = bytearray(0x25a3)
|
|
len = S.readinto(settingsBuf)
|
|
if len != 0x25a3 : sys.exit("Settings file is too short!")
|
|
if settingsBuf[0x258d:0x2594] != b'AT779UV' : sys.exit("Settings file is not an AT779UV CPS data file!")
|
|
# Preserve special scan limit and VFO memory channels.
|
|
specialList = []
|
|
specialNum = 0
|
|
while specialNum < 6 :
|
|
sBuf = bytearray(119)
|
|
len = S.readinto(sBuf)
|
|
if len < 119 : break
|
|
if int(struct.unpack('<H', sBuf[0:2])[0]) > 500 :
|
|
specialList.append(sBuf)
|
|
specialNum += 1
|
|
# Optional startup display update. Use command line quotes to includes spaces.
|
|
# Leave a line field blank to not update that line. Access CPS Function Setup
|
|
# to actually write this to the radio.
|
|
if display[0] : settingsBuf[0x1a22:0x1a2a] = bytes(display[0].ljust(8, ' '), 'utf-8')
|
|
if display[2] : settingsBuf[0x1a2a:0x1a32] = bytes(display[2].ljust(8, ' '), 'utf-8')
|
|
# Load and translate the CSV file into a memory channel buffer list.
|
|
with open(cName, 'r', newline='') as C :
|
|
R = csv.DictReader(C) # process CHIRP header
|
|
channelList = []
|
|
channelNum = 0
|
|
specialFlag = False
|
|
for row in R :
|
|
loc = int(row['Location'])
|
|
if loc < 1 or loc > 506 :
|
|
print("Dropping channel out of range with number", loc)
|
|
continue
|
|
if loc > 500 : specialFlag = True
|
|
channelList.append(translate(row))
|
|
channelNum += 1
|
|
# Create new CPS data file by combining the settings file and the CSV file channel data.
|
|
with open(dName, 'wb', buffering=0) as D :
|
|
# Update the number of memory channels in the settings data.
|
|
if specialFlag : settingsBuf[0x1a4c:0x1a4e] = struct.pack('<H', channelNum)
|
|
else : settingsBuf[0x1a4c:0x1a4e] = struct.pack('<H', channelNum+specialNum)
|
|
D.write(settingsBuf)
|
|
for buf in channelList : D.write(buf)
|
|
if not specialFlag :
|
|
for buf in specialList : D.write(buf)
|