From c494a3b9b2072965b98f4f03a4ec56a529ccdac7 Mon Sep 17 00:00:00 2001 From: "D.Bashkirtsev" Date: Thu, 15 Aug 2024 20:30:18 +0500 Subject: [PATCH 1/8] Pin major versions in requirements --- requirements.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements.txt b/requirements.txt index 92da97a..3ba4d83 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -geographiclib -geopy -maidenhead -mobile-codes -requests -tabulate -urllib3 +geographiclib~=2.0 +geopy~=2.4.1 +maidenhead~=1.7.0 +mobile-codes~=0.7 +requests~=2.32.3 +tabulate~=0.9.0 +urllib3~=2.2.2 \ No newline at end of file From adca7f2705f6c64ee4ee69f6fc32ca65d7b3e94d Mon Sep 17 00:00:00 2001 From: "D.Bashkirtsev" Date: Thu, 15 Aug 2024 22:30:25 +0500 Subject: [PATCH 2/8] Refactoring --- README.md | 18 ++-- templatex.py | 75 +++++++++++++++ utils.py | 29 ++++++ zone.py | 261 ++++++++++++++++++--------------------------------- 4 files changed, 204 insertions(+), 179 deletions(-) create mode 100644 templatex.py create mode 100644 utils.py diff --git a/README.md b/README.md index 19bc363..bfeeb84 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,14 @@ MOTOTRBO zone file generator from BrandMeister repeater list. It makes use of [B ## Installation * `git clone https://github.com/yl3im/motobm.git` -* `pip install -r requirements.txt` as root or `pip install -r requirements.txt --user` as ordinary user. +* `python -m venv ./motobm-env` +* `source ./motobm-env/bin/activate` +* `python -m pip install -r requirements.txt` ## Usage ``` -usage: ./zone.py [-h] [-f] -n NAME -b {vhf,uhf} -t {mcc,qth,gps} [-m MCC] [-q QTH] [-r RADIUS] [-lat LAT] [-lng LNG] [-p] [-6] [-zc ZONE_CAPACITY] +usage: python zone.py [-h] [-f] -n NAME -b {vhf,uhf} -t {mcc,qth,gps} [-m MCC] [-q QTH] [-r RADIUS] [-lat LAT] [-lng LNG] [-p] [-6] [-zc ZONE_CAPACITY] Generate MOTOTRBO zone files from BrandMeister. @@ -35,29 +37,29 @@ optional arguments: ## Examples -`./zone.py -n 'Germany' -b vhf -t mcc -m 262 -6 -zc 16` +`python zone.py -n 'Germany' -b vhf -t mcc -m 262 -6 -zc 16` will create XML zone file(s) with all German repeaters for 2m band with 6 digit ID (real repeaters, not just hotspots), split to 16 channels per one zone. -`./zone.py -n 'Lithuania' -b uhf -t mcc -m LT -6` +`python zone.py -n 'Lithuania' -b uhf -t mcc -m LT -6` will create XML zone file(s) with all Lithuanian repeaters for 70 band with 6 digit ID (real repeaters, not just hotspots). -`./zone.py -n 'Paris' -b uhf -t qth -q JN18EU -r 150 -6` +`python zone.py -n 'Paris' -b uhf -t qth -q JN18EU -r 150 -6` will create XML zone file(s) with all repeaters for 70cm band with 6 digit ID (real repeaters, not just hotspots) 150 kilometers around Paris. -`./zone.py -n 'Stockholm' -b uhf -t gps -lat 59.225 -lon 18.250 -6` +`python zone.py -n 'Stockholm' -b uhf -t gps -lat 59.225 -lon 18.250 -6` will create XML zone file(s) with all repeaters for 70cm band with 6 digit ID (real repeaters, not just hotspots) 100 kilometers around Stockholm. In case your latitude and/or longitude have negative values, please refer them this way to avoid errors: -`./zone.py -n 'Minneapolis' -b uhf -t gps -lat 44.9570 -lon " -93.2780" -6` +`python zone.py -n 'Minneapolis' -b uhf -t gps -lat 44.9570 -lon " -93.2780" -6` or -`./zone.py -n 'Minneapolis' -b uhf -t gps -lat 44.9570 -lon=-93.2780 -6` +`python zone.py -n 'Minneapolis' -b uhf -t gps -lat 44.9570 -lon=-93.2780 -6` While creating zone file(s) the script will also output the list of found repeaters like this: diff --git a/templatex.py b/templatex.py new file mode 100644 index 0000000..7d25dff --- /dev/null +++ b/templatex.py @@ -0,0 +1,75 @@ +ZONE = ''' + + + + + {channels} + + {zone_alias} + NORMAL + NONE + + + + +''' + +CONVENTIONAL_PERSONALITY_RX_TX = ''' + + DGTLCONV6PT25 + SLOT2 + {ch_cc} + {ch_rx} + {ch_tx} + True + {ch_alias} + MTCHCLRCD + True + SELECTED + True + DMR_UDP_HEADER + FOLLOW_CALL_DATA_SETTING + FOLLOW_ADMIT_CRITERIA + TMS + PROPRIETARY + +''' + +CONVENTIONAL_PERSONALITY = ''' + + DGTLCONV6PT25 + SLOT1 + {ch_cc} + {ch_rx} + {ch_tx} + True + {ch_alias} TS1 + MTCHCLRCD + True + SELECTED + True + DMR_UDP_HEADER + FOLLOW_CALL_DATA_SETTING + FOLLOW_ADMIT_CRITERIA + TMS + PROPRIETARY + + + DGTLCONV6PT25 + SLOT2 + {ch_cc} + {ch_rx} + {ch_tx} + True + {ch_alias} TS2 + MTCHCLRCD + True + SELECTED + True + DMR_UDP_HEADER + FOLLOW_CALL_DATA_SETTING + FOLLOW_ADMIT_CRITERIA + TMS + PROPRIETARY + + ''' diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..9ee993f --- /dev/null +++ b/utils.py @@ -0,0 +1,29 @@ +from os.path import exists + +import geopy.distance +import requests +import urllib3 + + +def download_file(f_path: str, url: str, overwrite: bool) -> bool: + if exists(f_path) and not overwrite: + return False + + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + response = requests.get(url, verify=False) + response.raise_for_status() + + with open(f_path, 'wb') as file: + file.write(response.content) + + return True + + +def check_distance(loc1, loc2): + return geopy.distance.great_circle(loc1, loc2).km + + +def write_text_file(f_path: str, text: str) -> None: + with open(f_path, "wt") as f: + f.write(text) diff --git a/zone.py b/zone.py index 805da13..54b6ba9 100755 --- a/zone.py +++ b/zone.py @@ -1,91 +1,58 @@ -#!/usr/bin/env python3 - import argparse import json -from os.path import exists -from tabulate import tabulate +import typing -import geopy.distance import maidenhead import mobile_codes -import requests -import urllib3 - -parser = argparse.ArgumentParser(description='Generate MOTOTRBO zone files from BrandMeister.') - -parser.add_argument('-f', '--force', action='store_true', - help='Forcibly download repeater list even if it exists locally.') -parser.add_argument('-n', '--name', required=True, help='Zone name. Choose it freely on your own.') -parser.add_argument('-b', '--band', choices=['vhf', 'uhf'], required=True, help='Repeater band.') - -parser.add_argument('-t', '--type', choices=['mcc', 'qth', 'gps'], required=True, - help='Select repeaters by MCC code, QTH locator index or GPS coordinates.') - -parser.add_argument('-m', '--mcc', help='First repeater ID digits, usually a 3 digits MCC. ' - 'You can also use a two letter country code instead.') -parser.add_argument('-q', '--qth', help='QTH locator index like KO26BX.') - -parser.add_argument('-r', '--radius', default=100, type=int, - help='Area radius in kilometers around the center of the chosen QTH locator. Defaults to 100.') - -parser.add_argument('-lat', type=float, help='Latitude of a GPS position.') -parser.add_argument('-lng', '-lon', type=float, help='Longitude of a GPS position.') - -parser.add_argument('-p', '--pep', action='store_true', help='Only select repeaters with defined power.') -parser.add_argument('-6', '--six', action='store_true', help='Only select repeaters with 6 digit ID.') -parser.add_argument('-zc', '--zone-capacity', default=160, type=int, - help='Channel capacity within zone. 160 by default as for top models, use 16 for the lite and ' - 'non-display ones.') - -args = parser.parse_args() - -bm_url = 'https://api.brandmeister.network/v2/device' -bm_file = 'BM.json' -filtered_list = [] -output_list = [] -existing = {} - -if args.type == 'qth': - qth_coords = maidenhead.to_location(args.qth, center=True) -if args.type == 'gps': - qth_coords = (args.lat, args.lng) +from tabulate import tabulate -if args.mcc and not str(args.mcc).isdigit(): - args.mcc = mobile_codes.alpha2(args.mcc)[4] +from templatex import ZONE, CONVENTIONAL_PERSONALITY_RX_TX, CONVENTIONAL_PERSONALITY +from utils import download_file, check_distance, write_text_file -def download_file(): - if not exists(bm_file) or args.force: - print(f'Downloading from {bm_url}') +def parse_args(): + parser = argparse.ArgumentParser(description='Generate MOTOTRBO zone files from BrandMeister.') - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + parser.add_argument('-f', '--force', action='store_true', + help='Forcibly download repeater list even if it exists locally.') + parser.add_argument('-n', '--name', required=True, help='Zone name. Choose it freely on your own.') + parser.add_argument('-b', '--band', choices=['vhf', 'uhf'], required=True, help='Repeater band.') - response = requests.get(bm_url, verify=False) - response.raise_for_status() + parser.add_argument('-t', '--type', choices=['mcc', 'qth', 'gps'], required=True, + help='Select repeaters by MCC code, QTH locator index or GPS coordinates.') - with open(bm_file, 'wb') as file: - file.write(response.content) + parser.add_argument('-m', '--mcc', help='First repeater ID digits, usually a 3 digits MCC. ' + 'You can also use a two letter country code instead.') + parser.add_argument('-q', '--qth', help='QTH locator index like KO26BX.') - print(f'Saved to {bm_file}') + parser.add_argument('-r', '--radius', default=100, type=int, + help='Area radius in kilometers around the center of the chosen QTH locator. Defaults to 100.') + parser.add_argument('-lat', type=float, help='Latitude of a GPS position.') + parser.add_argument('-lng', '-lon', type=float, help='Longitude of a GPS position.') -def check_distance(loc1, loc2): - return geopy.distance.great_circle(loc1, loc2).km + parser.add_argument('-p', '--pep', action='store_true', help='Only select repeaters with defined power.') + parser.add_argument('-6', '--six', action='store_true', help='Only select repeaters with 6 digit ID.') + parser.add_argument('-zc', '--zone-capacity', default=160, type=int, + help='Channel capacity within zone. 160 by default as for top models, use 16 for the lite and ' + 'non-display ones.') + return parser.parse_args() -def filter_list(): - global filtered_list - global existing - global qth_coords - f = open(bm_file, "r") +def filter_list(f_path, args, qth_coords: typing.Tuple[float, float]) -> typing.Tuple[typing.List[dict], dict]: + with open(f_path, "r") as f: + json_list = json.load(f) - json_list = json.loads(f.read()) sorted_list = sorted(json_list, key=lambda k: (k['callsign'], int(k["id"]))) + filtered_list = [] + existing = {} + for item in sorted_list: - if not ((args.band == 'vhf' and item['rx'].startswith('1')) or ( - args.band == 'uhf' and item['rx'].startswith('4'))): + band = args.band + rx = item['rx'] + if not ((band == 'vhf' and rx.startswith('1')) or (band == 'uhf' and rx.startswith('4'))): continue if args.type == 'mcc': @@ -102,8 +69,7 @@ def filter_list(): if not is_starts: continue - if (args.type == 'qth' or args.type == 'gps') and check_distance(qth_coords, - (item['lat'], item['lng'])) > args.radius: + if (args.type in ['qth', 'gps']) and check_distance(qth_coords, (item['lat'], item['lng'])) > args.radius: continue if args.pep and (not str(item['pep']).isdigit() or str(item['pep']) == '0'): @@ -121,18 +87,18 @@ def filter_list(): 'callsign']) for existing in filtered_list): continue - if not item['callsign'] in existing: existing[item['callsign']] = 0 + if not item['callsign'] in existing: + existing[item['callsign']] = 0 + existing[item['callsign']] += 1 item['turn'] = existing[item['callsign']] filtered_list.append(item) - f.close() + return filtered_list, existing -def process_channels(): - global output_list - +def process_channels(args, filtered_list: typing.List[dict], existing: typing.Dict) -> None: channel_chunks = [filtered_list[i:i + args.zone_capacity] for i in range(0, len(filtered_list), args.zone_capacity)] chunk_number = 0 @@ -142,7 +108,7 @@ def process_channels(): output_list = [] for item in chunk: - channels += format_channel(item) + channels += format_channel(item, existing, output_list) print('\n', tabulate(output_list, headers=['Callsign', 'RX', 'TX', 'CC', 'City', 'Last seen', 'URL'], @@ -154,110 +120,63 @@ def process_channels(): else: zone_alias = f'{args.name} #{chunk_number}' - write_zone_file(zone_alias, f''' - - - - - {channels} - - {zone_alias} - NORMAL - NONE - - - - -''') - - -def format_channel(item): - global existing - global output_list - - if existing[item['callsign']] == 1: - ch_alias = item['callsign'] + zone_alias += ".xml" + + write_text_file(zone_alias, ZONE.format(zone_alias=zone_alias, channels=channels)) + print(f'Zone file "{zone_alias}" written.\n') + + +def format_channel(item: typing.Dict, existing: typing.Dict, output_list: typing.List[typing.List]) -> str: + callsign = item['callsign'] + + if existing[callsign] == 1: + ch_alias = callsign else: - ch_alias = f"{item['callsign']} #{item['turn']}" + ch_alias = f"{callsign} #{item['turn']}" ch_rx = item['rx'] ch_tx = item['tx'] ch_cc = item['colorcode'] - output_list.append([ch_alias, ch_rx, ch_tx, ch_cc, item['city'], item['last_seen'], - f"https://brandmeister.network/?page=repeater&id={item['id']}"]) + output_list.append([ + ch_alias, ch_rx, ch_tx, ch_cc, item['city'], item['last_seen'], + f"https://brandmeister.network/?page=repeater&id={item['id']}" + ]) + + args = dict( + ch_alias=ch_alias, ch_cc=ch_cc, ch_rx=ch_rx, ch_tx=ch_tx + ) if item['rx'] == item['tx']: - return f''' - - DGTLCONV6PT25 - SLOT2 - {ch_cc} - {ch_rx} - {ch_tx} - True - {ch_alias} - MTCHCLRCD - True - SELECTED - True - DMR_UDP_HEADER - FOLLOW_CALL_DATA_SETTING - FOLLOW_ADMIT_CRITERIA - TMS - PROPRIETARY - - ''' - - return f''' - - DGTLCONV6PT25 - SLOT1 - {ch_cc} - {ch_rx} - {ch_tx} - True - {ch_alias} TS1 - MTCHCLRCD - True - SELECTED - True - DMR_UDP_HEADER - FOLLOW_CALL_DATA_SETTING - FOLLOW_ADMIT_CRITERIA - TMS - PROPRIETARY - - - DGTLCONV6PT25 - SLOT2 - {ch_cc} - {ch_rx} - {ch_tx} - True - {ch_alias} TS2 - MTCHCLRCD - True - SELECTED - True - DMR_UDP_HEADER - FOLLOW_CALL_DATA_SETTING - FOLLOW_ADMIT_CRITERIA - TMS - PROPRIETARY - - ''' - - -def write_zone_file(zone_alias, contents): - zone_file_name = zone_alias + ".xml" - zone_file = open(zone_file_name, "wt") - zone_file.write(contents) - zone_file.close() - print(f'Zone file "{zone_file_name}" written.\n') + return CONVENTIONAL_PERSONALITY_RX_TX.format(**args) + return CONVENTIONAL_PERSONALITY.format(**args) + + +def main(): + args = parse_args() + + bm_url = 'https://api.brandmeister.network/v2/device' + bm_file = 'BM.json' + + print(f'Downloading from {bm_url}') + if download_file(bm_file, bm_url, args.force): + print(f'Saved to {bm_file}') + else: + print(f'File {bm_file} already exists. Skip.') + + if args.type == 'qth': + qth_coords = maidenhead.to_location(args.qth, center=True) + elif args.type == 'gps': + qth_coords = (args.lat, args.lng) + else: + qth_coords = (0, 0) + + if args.mcc and not str(args.mcc).isdigit(): + args.mcc = mobile_codes.alpha2(args.mcc)[4] + + filtered_list, existing = filter_list(bm_file, args, qth_coords) + process_channels(args, filtered_list, existing) if __name__ == '__main__': - download_file() - filter_list() - process_channels() + main() From 3ef284cf521bb97ed74c0a826b359079abe14d2c Mon Sep 17 00:00:00 2001 From: "D.Bashkirtsev" Date: Thu, 15 Aug 2024 22:34:34 +0500 Subject: [PATCH 3/8] Add more typing --- zone.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/zone.py b/zone.py index 54b6ba9..4c6deb2 100755 --- a/zone.py +++ b/zone.py @@ -10,7 +10,7 @@ from utils import download_file, check_distance, write_text_file -def parse_args(): +def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description='Generate MOTOTRBO zone files from BrandMeister.') parser.add_argument('-f', '--force', action='store_true', @@ -40,7 +40,8 @@ def parse_args(): return parser.parse_args() -def filter_list(f_path, args, qth_coords: typing.Tuple[float, float]) -> typing.Tuple[typing.List[dict], dict]: +def filter_list(f_path: str, args: argparse.Namespace, qth_coords: typing.Tuple[float, float]) -> typing.Tuple[ + typing.List[dict], dict]: with open(f_path, "r") as f: json_list = json.load(f) @@ -98,7 +99,7 @@ def filter_list(f_path, args, qth_coords: typing.Tuple[float, float]) -> typing. return filtered_list, existing -def process_channels(args, filtered_list: typing.List[dict], existing: typing.Dict) -> None: +def process_channels(args: argparse.Namespace, filtered_list: typing.List[dict], existing: typing.Dict) -> None: channel_chunks = [filtered_list[i:i + args.zone_capacity] for i in range(0, len(filtered_list), args.zone_capacity)] chunk_number = 0 From edba23f976a29c782a878baa460631e8ff4f171d Mon Sep 17 00:00:00 2001 From: "D.Bashkirtsev" Date: Fri, 16 Aug 2024 12:10:47 +0500 Subject: [PATCH 4/8] Rename calc_distance; add typing --- utils.py | 3 ++- zone.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/utils.py b/utils.py index 9ee993f..c5e2f37 100644 --- a/utils.py +++ b/utils.py @@ -1,3 +1,4 @@ +import typing from os.path import exists import geopy.distance @@ -20,7 +21,7 @@ def download_file(f_path: str, url: str, overwrite: bool) -> bool: return True -def check_distance(loc1, loc2): +def calc_distance(loc1: typing.Tuple[float, float], loc2: typing.Tuple[float, float]) -> float: return geopy.distance.great_circle(loc1, loc2).km diff --git a/zone.py b/zone.py index 4c6deb2..6d5fed3 100755 --- a/zone.py +++ b/zone.py @@ -7,7 +7,7 @@ from tabulate import tabulate from templatex import ZONE, CONVENTIONAL_PERSONALITY_RX_TX, CONVENTIONAL_PERSONALITY -from utils import download_file, check_distance, write_text_file +from utils import download_file, calc_distance, write_text_file def parse_args() -> argparse.Namespace: @@ -70,7 +70,7 @@ def filter_list(f_path: str, args: argparse.Namespace, qth_coords: typing.Tuple[ if not is_starts: continue - if (args.type in ['qth', 'gps']) and check_distance(qth_coords, (item['lat'], item['lng'])) > args.radius: + if (args.type in ['qth', 'gps']) and calc_distance(qth_coords, (item['lat'], item['lng'])) > args.radius: continue if args.pep and (not str(item['pep']).isdigit() or str(item['pep']) == '0'): From 0f22b41811a278953eca0f1229514a54e1ad2ea1 Mon Sep 17 00:00:00 2001 From: "D.Bashkirtsev" Date: Fri, 16 Aug 2024 12:11:46 +0500 Subject: [PATCH 5/8] Refactoring and optimization --- zone.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/zone.py b/zone.py index 6d5fed3..cf32c47 100755 --- a/zone.py +++ b/zone.py @@ -59,10 +59,11 @@ def filter_list(f_path: str, args: argparse.Namespace, qth_coords: typing.Tuple[ if args.type == 'mcc': is_starts = False - if type(args.mcc) is list: + if isinstance(args.mcc, list): for mcc in args.mcc: if str(item['id']).startswith(mcc): is_starts = True + break else: if str(item['id']).startswith(args.mcc): is_starts = True From f251deedf7f72b91f339d4117a5b4dd81befcfba Mon Sep 17 00:00:00 2001 From: "D.Bashkirtsev" Date: Fri, 16 Aug 2024 12:16:39 +0500 Subject: [PATCH 6/8] Code formatting --- zone.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/zone.py b/zone.py index cf32c47..656e9a8 100755 --- a/zone.py +++ b/zone.py @@ -85,8 +85,11 @@ def filter_list(f_path: str, args: argparse.Namespace, qth_coords: typing.Tuple[ item['callsign'] = item['callsign'].split()[0] - if any((existing['rx'] == item['rx'] and existing['tx'] == item['tx'] and existing['callsign'] == item[ - 'callsign']) for existing in filtered_list): + if any( + (existing['rx'] == item['rx'] and + existing['tx'] == item['tx'] and + existing['callsign'] == item['callsign']) + for existing in filtered_list): continue if not item['callsign'] in existing: From 39ab38d63df24ddd05eb7d6f27fa2af834a56035 Mon Sep 17 00:00:00 2001 From: "D.Bashkirtsev" Date: Fri, 16 Aug 2024 12:18:05 +0500 Subject: [PATCH 7/8] Code formatting --- zone.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/zone.py b/zone.py index 656e9a8..6a3e178 100755 --- a/zone.py +++ b/zone.py @@ -85,11 +85,10 @@ def filter_list(f_path: str, args: argparse.Namespace, qth_coords: typing.Tuple[ item['callsign'] = item['callsign'].split()[0] - if any( - (existing['rx'] == item['rx'] and - existing['tx'] == item['tx'] and - existing['callsign'] == item['callsign']) - for existing in filtered_list): + if any((existing['rx'] == item['rx'] and + existing['tx'] == item['tx'] and + existing['callsign'] == item['callsign']) + for existing in filtered_list): continue if not item['callsign'] in existing: From 9222a3a8eaa793b466e92e6fbf5eaa47c252a454 Mon Sep 17 00:00:00 2001 From: "D.Bashkirtsev" Date: Fri, 16 Aug 2024 12:20:53 +0500 Subject: [PATCH 8/8] Small refactoring --- zone.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/zone.py b/zone.py index 6a3e178..67fc231 100755 --- a/zone.py +++ b/zone.py @@ -147,13 +147,9 @@ def format_channel(item: typing.Dict, existing: typing.Dict, output_list: typing f"https://brandmeister.network/?page=repeater&id={item['id']}" ]) - args = dict( - ch_alias=ch_alias, ch_cc=ch_cc, ch_rx=ch_rx, ch_tx=ch_tx - ) - - if item['rx'] == item['tx']: - return CONVENTIONAL_PERSONALITY_RX_TX.format(**args) - return CONVENTIONAL_PERSONALITY.format(**args) + template = CONVENTIONAL_PERSONALITY_RX_TX if item['rx'] == item['tx'] else CONVENTIONAL_PERSONALITY + template_args = dict(ch_alias=ch_alias, ch_cc=ch_cc, ch_rx=ch_rx, ch_tx=ch_tx) + return template.format(**template_args) def main():