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