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/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 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..c5e2f37 --- /dev/null +++ b/utils.py @@ -0,0 +1,30 @@ +import typing +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 calc_distance(loc1: typing.Tuple[float, float], loc2: typing.Tuple[float, float]) -> float: + 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..67fc231 100755 --- a/zone.py +++ b/zone.py @@ -1,100 +1,69 @@ -#!/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, calc_distance, write_text_file -def download_file(): - if not exists(bm_file) or args.force: - print(f'Downloading from {bm_url}') +def parse_args() -> argparse.Namespace: + 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: 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) - 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': 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 @@ -102,8 +71,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 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'): @@ -117,22 +85,24 @@ def filter_list(): 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: 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: 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 @@ -142,7 +112,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 +124,59 @@ 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']}"]) - - 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') + 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']}" + ]) + + 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(): + 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()