├── requirements.txt ├── resources ├── settings.png ├── Fldigi-settings.png ├── Logger-diagram.png ├── N1MM-radio-info.png ├── N1MM-settings.png ├── SDR++-settings.png ├── Flrig-CAT-config.png ├── RUMlogNG-settings.png ├── Flrig-server-diagram.png ├── rig-server-screen-shot.png ├── DXLab-Commander-settings.png ├── RUMlogNG-flrig-settings.png ├── cat-relay configurations.png └── Flrig-server-config-screen.png ├── diagrams └── user configurations.key ├── docs └── Commander TCPIP Messages.pdf ├── gui_components ├── dropdown.py ├── seconds_input.py └── text_input.py ├── utils ├── tcp_client.py └── cat_client.py ├── radio_control ├── fldigi.py ├── radio_info.py ├── transport.py ├── dxlab.py ├── flrig.py └── n1mm.py ├── .gitignore ├── sdr_control └── hamlib.py ├── README.md ├── settings.py ├── .github └── workflows │ └── build-apps.yaml ├── app.py ├── cat_relay.py └── config.py /requirements.txt: -------------------------------------------------------------------------------- 1 | pyyaml 2 | requests 3 | PySide6~=6.7.2 4 | -------------------------------------------------------------------------------- /resources/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n2iw/cat-relay/HEAD/resources/settings.png -------------------------------------------------------------------------------- /resources/Fldigi-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n2iw/cat-relay/HEAD/resources/Fldigi-settings.png -------------------------------------------------------------------------------- /resources/Logger-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n2iw/cat-relay/HEAD/resources/Logger-diagram.png -------------------------------------------------------------------------------- /resources/N1MM-radio-info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n2iw/cat-relay/HEAD/resources/N1MM-radio-info.png -------------------------------------------------------------------------------- /resources/N1MM-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n2iw/cat-relay/HEAD/resources/N1MM-settings.png -------------------------------------------------------------------------------- /resources/SDR++-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n2iw/cat-relay/HEAD/resources/SDR++-settings.png -------------------------------------------------------------------------------- /resources/Flrig-CAT-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n2iw/cat-relay/HEAD/resources/Flrig-CAT-config.png -------------------------------------------------------------------------------- /resources/RUMlogNG-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n2iw/cat-relay/HEAD/resources/RUMlogNG-settings.png -------------------------------------------------------------------------------- /diagrams/user configurations.key: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n2iw/cat-relay/HEAD/diagrams/user configurations.key -------------------------------------------------------------------------------- /docs/Commander TCPIP Messages.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n2iw/cat-relay/HEAD/docs/Commander TCPIP Messages.pdf -------------------------------------------------------------------------------- /resources/Flrig-server-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n2iw/cat-relay/HEAD/resources/Flrig-server-diagram.png -------------------------------------------------------------------------------- /resources/rig-server-screen-shot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n2iw/cat-relay/HEAD/resources/rig-server-screen-shot.png -------------------------------------------------------------------------------- /resources/DXLab-Commander-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n2iw/cat-relay/HEAD/resources/DXLab-Commander-settings.png -------------------------------------------------------------------------------- /resources/RUMlogNG-flrig-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n2iw/cat-relay/HEAD/resources/RUMlogNG-flrig-settings.png -------------------------------------------------------------------------------- /resources/cat-relay configurations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n2iw/cat-relay/HEAD/resources/cat-relay configurations.png -------------------------------------------------------------------------------- /resources/Flrig-server-config-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n2iw/cat-relay/HEAD/resources/Flrig-server-config-screen.png -------------------------------------------------------------------------------- /gui_components/dropdown.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtWidgets import QWidget, QLabel, QVBoxLayout, QComboBox 2 | 3 | 4 | 5 | class Dropdown(QWidget): 6 | def __init__(self, label, value, value_list, placeholder, handler, disabled = False): 7 | super().__init__() 8 | 9 | layout = QVBoxLayout() 10 | message = QLabel(f'{label}: ') 11 | layout.addWidget(message) 12 | 13 | dropdown = QComboBox() 14 | if value not in value_list: 15 | dropdown.addItem(placeholder) 16 | handler(placeholder) 17 | 18 | for software in value_list: 19 | dropdown.addItem(software) 20 | 21 | if value in value_list: 22 | dropdown.setCurrentText(value) 23 | 24 | dropdown.currentTextChanged.connect(handler) 25 | layout.addWidget(dropdown) 26 | dropdown.setDisabled(disabled) 27 | 28 | self.setLayout(layout) 29 | 30 | -------------------------------------------------------------------------------- /utils/tcp_client.py: -------------------------------------------------------------------------------- 1 | import re 2 | import socket 3 | 4 | BUFFER_SIZE = 1024 5 | SOCKET_TIMEOUT = 1 6 | 7 | class TCPClient: 8 | def __init__(self, ip, port): 9 | self._ip = ip 10 | self._port = port 11 | self._sock = None 12 | 13 | def __enter__(self): 14 | self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 15 | self._sock.settimeout(SOCKET_TIMEOUT) 16 | self._sock.connect((self._ip, self._port)) 17 | self._enter() 18 | return self 19 | 20 | def send(self, message): 21 | self._sock.send(bytes(message, 'utf-8')) 22 | 23 | def receive(self): 24 | msg = str(self._sock.recv(BUFFER_SIZE)) 25 | result = re.match(r"b\'(.+)\'", str(msg)) 26 | if result: 27 | return result.group(1) 28 | 29 | def close(self): 30 | if self._sock: 31 | self._sock.close() 32 | self._sock = None 33 | 34 | def __exit__(self, exc_type, exc_val, exc_tb): 35 | self.close() 36 | -------------------------------------------------------------------------------- /gui_components/seconds_input.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtWidgets import QWidget, QLabel, QHBoxLayout, QSpinBox, QDoubleSpinBox 2 | 3 | 4 | class SecondsInputBase(QWidget): 5 | def __init__(self, label, value, min_value, max_value, step, handler, widget_class): 6 | super().__init__() 7 | 8 | layout = QHBoxLayout() 9 | message = QLabel(f'{label}: ') 10 | layout.addWidget(message) 11 | 12 | slider = widget_class() 13 | slider.setValue(value) 14 | slider.setRange(min_value, max_value) 15 | slider.setSingleStep(step) 16 | 17 | slider.valueChanged.connect(handler) 18 | layout.addWidget(slider) 19 | 20 | self.setLayout(layout) 21 | 22 | class WholeSecondsInput(SecondsInputBase): 23 | def __init__(self, label, value, min_value, max_value, step, handler): 24 | super().__init__(label, value, min_value, max_value, step, handler, QSpinBox) 25 | 26 | class DecimalSecondsInput(SecondsInputBase): 27 | def __init__(self, label, value, min_value, max_value, step, handler): 28 | super().__init__(label, value, min_value, max_value, step, handler, QDoubleSpinBox) 29 | -------------------------------------------------------------------------------- /gui_components/text_input.py: -------------------------------------------------------------------------------- 1 | from inspect import isfunction 2 | 3 | from PySide6.QtWidgets import QWidget, QLabel, QLineEdit, QHBoxLayout 4 | 5 | INTEGER = 'integer' 6 | TEXT = 'text' 7 | 8 | class TextInput(QWidget): 9 | def __init__(self, label, value, handler, visible = True, data_type = TEXT): 10 | super().__init__() 11 | 12 | self.handler = handler 13 | self.data_type = data_type 14 | 15 | layout = QHBoxLayout() 16 | message = QLabel(f'{label}: ') 17 | layout.addWidget(message) 18 | 19 | self.line = QLineEdit(str(value)) 20 | self.line.textChanged.connect(self.text_changed) 21 | layout.addWidget(self.line) 22 | 23 | if not visible: 24 | self.hide() 25 | 26 | self.setLayout(layout) 27 | 28 | def text_changed(self, text): 29 | if isfunction(self.handler): 30 | data = text 31 | if self.data_type == INTEGER: 32 | data = int(text) 33 | self.handler(data) 34 | 35 | def set_visibility(self, visible): 36 | if visible: 37 | self.show() 38 | else: 39 | self.hide() 40 | 41 | -------------------------------------------------------------------------------- /utils/cat_client.py: -------------------------------------------------------------------------------- 1 | from utils.tcp_client import TCPClient 2 | 3 | class CATClient(TCPClient): 4 | ALTERNATIVE_MODES = { 5 | 'RTTY': 'USB', # RTTY mode from radio will be mapped to USB mode 6 | 'WFM': 'FM', # WFM mode from SDR++ will be mapped to FM mode 7 | 'RAW': None, # RAW mode from SDR++ will be disabled 8 | 'DSB': None # DSB mode from SDR++ will be disabled 9 | } 10 | 11 | def __init__(self, ip, port): 12 | super().__init__(ip, port) 13 | self._last_mode = '' 14 | self._last_freq = 0 15 | 16 | def _enter(self): 17 | self._last_freq = self.get_freq() 18 | self._last_mode = self.get_mode() 19 | 20 | def map_mode(self, mode): 21 | valid_mode = mode 22 | if mode in self.ALTERNATIVE_MODES: 23 | valid_mode = self.ALTERNATIVE_MODES[mode] 24 | 25 | return valid_mode 26 | 27 | def set_last_mode(self, mode): 28 | if mode: 29 | self._last_mode = mode 30 | 31 | def get_last_mode(self): 32 | return self._last_mode 33 | 34 | def set_last_freq(self, freq): 35 | if freq and isinstance(freq, int): 36 | self._last_freq = freq 37 | 38 | def get_last_freq(self): 39 | return self._last_freq 40 | -------------------------------------------------------------------------------- /radio_control/fldigi.py: -------------------------------------------------------------------------------- 1 | import pyfldigi 2 | from utils.tcp_client import TCPClient 3 | 4 | VALID_MODES = [ 5 | 'LSB', 6 | 'USB', 7 | 'AM', 8 | 'CW', 9 | 'RTTY', 10 | 'FM', 11 | 'WFM', 12 | 'CW-R', 13 | 'RTTY-R', 14 | 'DV', 15 | 'LSB-D', 16 | 'USB-D', 17 | 'AM-D', 18 | 'FM-D' 19 | ] 20 | 21 | class FldigiClient(TCPClient): 22 | 23 | def __init__(self, ip, port): 24 | self.last_mode = None 25 | self.last_freq = None 26 | self._ip = ip 27 | self._port = port 28 | self._sock = None 29 | 30 | def __enter__(self): 31 | # self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 32 | # self._sock.connect((self._ip, self._port)) 33 | # self._enter() 34 | self.client = pyfldigi.Client(self._ip, self._port) 35 | return self 36 | 37 | def set_freq_mode(self, raw_freq, mode): 38 | freq = int(raw_freq) 39 | if freq and self.last_freq != freq: 40 | self.client.rig.frequency = freq 41 | self.last_freq = freq 42 | 43 | if mode and self.last_mode != mode: 44 | radiomode = mode 45 | if (mode == "USB"): 46 | radiomode = "USB-D" 47 | if (mode == "LSB"): 48 | radiomode = "LSB-D" 49 | self.client.rig.mode = radiomode 50 | self.last_mode = mode 51 | 52 | 53 | def get_last_freq(self): 54 | return int(self.last_freq) 55 | 56 | def get_last_mode(self): 57 | return self.last_mode 58 | 59 | def get_freq(self): 60 | self.last_freq = self.client.rig.frequency 61 | return int(self.get_last_freq()) 62 | 63 | def get_mode(self): 64 | radiomode = self.client.rig.mode 65 | sdrmode = radiomode 66 | if (radiomode == "USB-D"): 67 | sdrmode = "USB" 68 | if (radiomode == "LSB-D"): 69 | sdrmode = "LSB" 70 | self.last_mode = sdrmode 71 | return self.get_last_mode() 72 | -------------------------------------------------------------------------------- /radio_control/radio_info.py: -------------------------------------------------------------------------------- 1 | import xml.etree.ElementTree as ET 2 | 3 | FREQUENCY = 'Freq1' 4 | 5 | APP_NAME = 'Cat-Relay' 6 | 7 | 8 | def read_child(data, name): 9 | result = None 10 | child = data.find(name) 11 | if child is not None: 12 | result = child.text 13 | 14 | return result 15 | 16 | 17 | def get_radio_info(xml_data): 18 | root = ET.fromstring(xml_data) 19 | if root.tag == RadioInfo.TAG_NAME: 20 | return RadioInfo(root) 21 | else: 22 | print(f'"{root.tag}" tag received, but not supported!') 23 | 24 | return None 25 | 26 | 27 | def set_frequency_message(freq): 28 | cmd = ET.Element('radio_setfrequency') 29 | app = ET.SubElement(cmd, 'app') 30 | app.text = APP_NAME 31 | radio_nr = ET.SubElement(cmd, 'radionr') 32 | radio_nr.text = '1' 33 | frequency = ET.SubElement(cmd, 'frequency') 34 | frequency.text = f'{freq/1000:.3f}' 35 | message = ET.tostring(cmd, xml_declaration=True) 36 | return message 37 | 38 | 39 | class RadioInfo: 40 | TAG_NAME = 'RadioInfo' 41 | elements = [ 42 | 'StationName', 43 | 'RadioNr', 44 | 'Freq', 45 | 'TXFreq', 46 | 'Mode', 47 | 'FocusRadioNr' 48 | ] 49 | 50 | def get_frequency(self): 51 | ''' 52 | :return: frequency in Hz 53 | ''' 54 | if self.data and 'Freq' in self.data: 55 | return int(self.data['Freq']) * 10 56 | 57 | def get_mode(self): 58 | ''' 59 | :return: mode 60 | ''' 61 | if self.data and 'Mode' in self.data: 62 | return self.data['Mode'] 63 | 64 | def __init__(self, root): 65 | if root.tag == self.TAG_NAME: 66 | self.data = {} 67 | for element in self.elements: 68 | value = read_child(root, element) 69 | if value is not None: 70 | self.data[element] = value 71 | else: 72 | raise f'Data received is not a RadioInfo data' 73 | 74 | -------------------------------------------------------------------------------- /radio_control/transport.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """A replacement transport for Python xmlrpc library. 4 | 5 | cat_relay note: Shamelessly borrowed from pyfldigi, which in turn borrowed it from 6 | 7 | https://github.com/astraw/stdeb/blob/master/stdeb/transport.py 8 | 9 | ..with a few modifications, mainly to make it python 3 friendly, and to get rid of vestigial cruft. 10 | Oh, and I made it HTTP only because flrig doesn't support HTTPS as far as I know. 11 | The file was originally released under the MIT license""" 12 | 13 | import xmlrpc 14 | import requests 15 | import requests.utils 16 | 17 | class RequestsTransport(xmlrpc.client.Transport): 18 | """Drop in Transport for xmlrpclib that uses Requests instead of httplib. 19 | 20 | Inherits xml.client.Transport and is meant to be passed directly to xmlrpc.ServerProxy constructor. 21 | 22 | :example: 23 | 24 | >>> import xmlrpc.client 25 | >>> from radio_control.transport import RequestsTransport 26 | >>> s = xmlrpc.client.ServerProxy('http://yoursite.com/xmlrpc', transport=RequestsTransport()) 27 | >>> s.demo.sayHello() 28 | Hello! 29 | 30 | """ 31 | # change our user agent to reflect Requests 32 | user_agent = 'Python-xmlrpc with Requests (python-requests.org)' 33 | 34 | def request(self, host, handler, request_body, verbose): 35 | """Make an xmlrpc request.""" 36 | headers = {'User-Agent': self.user_agent, 'Content-Type': 'text/xml'} 37 | url = self._build_url(host, handler) 38 | resp = requests.post(url, data=request_body, headers=headers) 39 | try: 40 | resp.raise_for_status() 41 | except requests.RequestException as e: 42 | raise xmlrpc.client.ProtocolError(url, resp.status_code, str(e), resp.headers) 43 | else: 44 | return self.parse_response(resp) 45 | 46 | def parse_response(self, resp): 47 | """Parse the xmlrpc response.""" 48 | p, u = self.getparser() # returns (parser, target) 49 | p.feed(resp.text) 50 | p.close() 51 | return u.close() 52 | 53 | def _build_url(self, host, handler): 54 | """Build a url for our request based on the host, handler and use_http property""" 55 | return 'http://{}/{}'.format(host, handler) 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | # Pyre type checker 124 | .pyre/ 125 | 126 | .idea/ 127 | .DS_Store -------------------------------------------------------------------------------- /radio_control/dxlab.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from utils.cat_client import CATClient 4 | 5 | 6 | def format_command(field, children = None): 7 | if children is None: 8 | children = '' 9 | else: 10 | children = str(children) 11 | # Make sure every parameter is string 12 | children_len = len(children) 13 | field = str(field) 14 | return f'<{field}:{children_len}>{children}' 15 | 16 | 17 | def parse_frequency(message): 18 | result = re.match(r" *([\d,]+\.\d+)", message) 19 | if result: 20 | freq_str = result.group(1) 21 | # remove ',' returned by Commander 22 | freq_str = freq_str.replace(',', '') 23 | if freq_str: 24 | return int(float(freq_str) * 1000) 25 | 26 | 27 | def parse_mode(message): 28 | result = re.match(r"(\w+)", message) 29 | if result: 30 | mode = result.group(1) 31 | return mode 32 | 33 | 34 | def format_freq(freq): 35 | # return f'{freq/1000:,.3f}' 36 | # has to add leading 0s to make MacLogger DX work, DXlab and RUMlogNG don't need it 37 | return f'{freq/1000:>010,.3f}' 38 | 39 | 40 | 41 | 42 | VALID_MODES = [ 43 | 'AM', 44 | 'CW', 45 | 'CW-R', 46 | 'DATA-L', 47 | 'DATA-U', 48 | 'FM', 49 | 'LSB', 50 | 'RTTY', 51 | 'RTTY-R', 52 | 'USB', 53 | 'WBFM' 54 | ] 55 | 56 | 57 | class Commander(CATClient): 58 | def get_freq(self): 59 | cmd = format_command('command', 'CmdGetFreq') + format_command('parameters') 60 | self.send(cmd) 61 | self.set_last_freq(parse_frequency(self.receive())) 62 | return self.get_last_freq() 63 | 64 | def get_mode(self): 65 | cmd = format_command('command', 'CmdSendMode') + format_command('parameters') 66 | self.send(cmd) 67 | mode = self.map_mode(parse_mode(self.receive())) 68 | if mode: 69 | self.set_last_mode(mode) 70 | return self.get_last_mode() 71 | 72 | def set_freq_mode(self, freq, mode=None): 73 | if mode and self.get_last_mode() != mode: 74 | parameters = format_command('xcvrfreq', format_freq(freq)) + format_command('xcvrmode', mode) 75 | cmd = format_command('command', 'CmdSetFreqMode') + format_command('parameters', parameters) 76 | elif freq and self.get_last_freq() != freq: 77 | parameters = format_command('xcvrfreq', format_freq(freq)) 78 | cmd = format_command('command', 'CmdSetFreq') + format_command('parameters', parameters) 79 | else: 80 | return 81 | self.send(cmd) 82 | self.set_last_mode(mode) 83 | self.set_last_freq(freq) 84 | -------------------------------------------------------------------------------- /sdr_control/hamlib.py: -------------------------------------------------------------------------------- 1 | ## While not exactly clear from the file name, this module interacts with the SDR++ app 2 | 3 | ## It is a lightweight substitute for a full Hamlib implementation 4 | ## which has many dependences and would be complete overkill for our use. 5 | ## It supports only three methods: 6 | ## set_freq_mode, which sets the frequency and mode of the SDR 7 | ## get_freq, which returns the frequency of the SDR 8 | ## get_mode, which returns the mode of the SDR 9 | 10 | ## As mentioned in the README file, this would probably work with pretty much any SDR 11 | ## software that implements the f, m, F, and M Hamlib functions that 12 | ## read and write the frequency and mode. 13 | 14 | import re 15 | from utils.cat_client import CATClient 16 | 17 | 18 | def parse_frequency(message): 19 | result = re.match(r"(\d+)\\n", str(message)) 20 | if result: 21 | freq_str = result.group(1) 22 | if freq_str: 23 | return int(freq_str) 24 | 25 | 26 | def parse_mode(message): 27 | result = re.match(r"(\w+)\\", str(message)) 28 | if result: 29 | freq_str = result.group(1) 30 | if freq_str: 31 | return freq_str 32 | 33 | 34 | def parse_result(message): 35 | result = re.match(r"RPRT +(\d)\\n", str(message)) 36 | if result: 37 | succeeded = result.group(1) 38 | return succeeded == '0' 39 | 40 | 41 | VALID_MODES = [ 42 | 'AM', 43 | 'AMS', 44 | 'CW', 45 | 'CWR', 46 | 'DSB' 47 | 'ECSSLSB', 48 | 'ECSSUSB', 49 | 'FA', 50 | 'FM', 51 | 'LSB', 52 | 'PKTFM', 53 | 'PKTLSB', 54 | 'PKTUSB', 55 | 'RTTY', 56 | 'RTTYR', 57 | 'SAH', 58 | 'SAL', 59 | 'SAM', 60 | 'USB', 61 | 'WFM', 62 | ] 63 | 64 | 65 | class HamLibClient(CATClient): 66 | 67 | def set_freq_mode(self, freq, mode=None): 68 | if mode and self.get_last_mode() != mode: 69 | message = f'M {mode} -1\n' 70 | self.send(message) 71 | result = self.receive() 72 | if parse_result(result): 73 | self.set_last_mode(mode) 74 | else: 75 | print(f'Set Hamlib to {mode} mode failed!') 76 | 77 | if freq and self.get_last_freq() != freq: 78 | message = f'F {freq}\n' 79 | self.send(message) 80 | result = self.receive() 81 | if parse_result(result): 82 | self.set_last_freq(freq) 83 | else: 84 | print(f'Set Hamlib to {freq}Hz failed!') 85 | 86 | def get_freq(self): 87 | message = f'f\n' 88 | self.send(message) 89 | self.set_last_freq(parse_frequency(self.receive())) 90 | return self.get_last_freq() 91 | 92 | def get_mode(self): 93 | message = f'm\n' 94 | self.send(message) 95 | self.set_last_mode(self.map_mode(parse_mode(self.receive()))) 96 | return self.get_last_mode() 97 | -------------------------------------------------------------------------------- /radio_control/flrig.py: -------------------------------------------------------------------------------- 1 | import xmlrpc.client 2 | from .transport import RequestsTransport 3 | from requests.exceptions import ConnectionError 4 | 5 | ## The following are the valid modes that can be used on my Icom IC-7100. They may require changing for 6 | ## your radio. Note that we use two dictionaries here: RADIO_TO_SDR converts the string we get from the 7 | ## transceiver to the mode that the SDR can understand. SDR_TO_RADIO converts the SDR string to what the 8 | ## radio wants. 9 | 10 | ## Note that if you want to focus on voice modes, you should probably translate "USB" to "USB" 11 | ## instead of "USB" to "USB-D" because USB-D is meant for digital decoding and may mute the rig microphone, 12 | ## depending on how you have your USB connection set on the radio. The same is true for "LSB" and "AM" modes. 13 | 14 | RADIO_TO_SDR = { 15 | 'LSB':'LSB', 16 | 'USB':'USB', 17 | 'AM':'AM', 18 | 'CW':'CW', 19 | 'RTTY':'USB', 20 | 'FM':'NFM', 21 | 'WFM':'WFM', 22 | 'CW-R':'CW', 23 | 'RTTY-R':'USB', 24 | 'DV':'NFM', 25 | 'LSB-D':'LSB', 26 | 'USB-D':'USB', 27 | 'AM-D':'AM', 28 | 'FM-D':'NFM' 29 | } 30 | 31 | SDR_TO_RADIO = { 32 | 'LSB':'LSB-D', 33 | 'USB':'USB-D', 34 | 'AM':'AM-D', 35 | 'DSB':'AM', 36 | 'NFM':'FM', 37 | 'WFM':'WFM', 38 | 'CW':'CW', 39 | 'RAW':'USB' 40 | } 41 | 42 | 43 | class FlrigClient(): 44 | 45 | def __init__(self, ip, port): 46 | self.last_mode = None 47 | self.last_freq = None 48 | self._ip = ip 49 | self._port = port 50 | self._sock = None 51 | 52 | def __enter__(self): 53 | # self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 54 | # self._sock.connect((self._ip, self._port)) 55 | # self._enter() 56 | # print(f'Attempting to connect to connect to Flrig at IP address={self._ip}, port={self._port}, via XMP-RPC') 57 | try: 58 | self.flrig = xmlrpc.client.ServerProxy('http://{}:{}/'.format(self._ip, self._port), transport=RequestsTransport(use_builtin_types=True), allow_none=True) 59 | except ConnectionError as e: 60 | print(e) 61 | print("Are you sure flrig is running?") 62 | return self 63 | 64 | def __exit__(self, a, b, c): 65 | return 66 | 67 | def close(self): 68 | self.flrig = None 69 | 70 | def set_freq_mode(self, raw_freq, mode): 71 | freq = float(raw_freq) 72 | if freq and self.last_freq != freq: 73 | self.flrig.rig.set_frequency(freq) 74 | self.last_freq = freq 75 | 76 | if mode and self.last_mode != mode: 77 | radiomode = SDR_TO_RADIO[mode] 78 | self.flrig.rig.set_mode(radiomode) 79 | self.last_mode = mode 80 | 81 | def get_last_freq(self): 82 | return int(self.last_freq) 83 | 84 | def get_last_mode(self): 85 | return self.last_mode 86 | 87 | def get_freq(self): 88 | self.last_freq = self.flrig.rig.get_vfo() 89 | return int(self.last_freq) 90 | 91 | def get_mode(self): 92 | radiomode = self.flrig.rig.get_mode() 93 | sdrmode = RADIO_TO_SDR[radiomode] 94 | self.last_mode = sdrmode 95 | return self.get_last_mode() 96 | -------------------------------------------------------------------------------- /radio_control/n1mm.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import threading 3 | 4 | from .radio_info import get_radio_info, set_frequency_message 5 | 6 | 7 | def parse_frequency_mode(data): 8 | info = get_radio_info(data) 9 | if info: 10 | freq = info.get_frequency() 11 | mode = map_mode(info.get_mode(), freq) 12 | return freq, mode 13 | else: 14 | return None 15 | 16 | 17 | # Map 'SSB' to 'USB' or 'LSB' base on frequency 18 | # 'RTTY' to 'USB' 19 | def map_mode(mode, freq): 20 | if mode == 'SSB': 21 | return 'USB' if freq > 10_000_000 else 'LSB' 22 | elif mode == 'RTTY': 23 | return 'USB' 24 | return mode 25 | 26 | 27 | class N1MMClient: 28 | def __init__(self, listen_port, send_ip, send_port): 29 | self.listen_port = listen_port 30 | self.send_ip = send_ip 31 | self.send_port = send_port 32 | self.listen_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 33 | self.send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 34 | self.last_mode = '' 35 | self.last_freq = 0 36 | self.terminated = False # flag to terminate the thread 37 | 38 | def __enter__(self): 39 | self.listen_sock.bind(('0.0.0.0', self.listen_port)) 40 | self.thread = threading.Thread(target=self.listen) 41 | self.thread.start() 42 | return self 43 | 44 | # This function will be running in a separate thread and updates self.last_mode and self.last_freq 45 | def listen(self): 46 | print(f'listening UDP in a new thread') 47 | while True: 48 | if self.terminated: 49 | print(f'Terminated flag detected, terminate the thread') 50 | return 51 | data = self.receive() 52 | freq, mode = parse_frequency_mode(data) 53 | if freq: 54 | self.last_freq = freq 55 | self.last_mode = mode 56 | 57 | def send(self, message): 58 | if isinstance(message, str): 59 | b_msg = bytes(message, 'utf-8') 60 | else: 61 | b_msg = message 62 | self.send_sock.sendto(b_msg, (self.send_ip, self.send_port)) 63 | 64 | def receive(self): 65 | data, addr = self.listen_sock.recvfrom(1024) # buffer size is 1024 bytes 66 | return data 67 | 68 | def close(self): 69 | self.terminated = True 70 | self.thread.join() 71 | if self.listen_sock: 72 | self.listen_sock.close() 73 | self.listen_sock = None 74 | if self.send_sock: 75 | self.send_sock.close() 76 | self.send_sock = None 77 | 78 | def get_last_mode(self): 79 | return self.last_mode 80 | 81 | def get_last_freq(self): 82 | return self.last_freq 83 | 84 | def __exit__(self, exc_type, exc_val, exc_tb): 85 | self.close() 86 | 87 | def get_freq(self): 88 | return self.last_freq 89 | 90 | def get_mode(self): 91 | return self.last_mode 92 | 93 | # set frequency only, mode is not supported in N1MM 94 | def set_freq_mode(self, freq, mode=None): 95 | print(f'Set freq: {freq}, mode: {mode}') 96 | cmd = set_frequency_message(freq) 97 | if cmd: 98 | self.last_freq = freq 99 | self.send(cmd) 100 | 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #Cat-relay 2 | 3 | ## About 4 | Cat-relay is a tool to turn your SDR into a band scope/panadapter 5 | for your radio. It does this by synchronizing the frequencies and modes 6 | of your SDR software and your radio transceiver. 7 | 8 | ## Download 9 | Please go to [Release Page](https://github.com/n2iw/cat-relay/releases) to download latest version of Cat-relay. 10 | Mac and Windows versions don't require any installation, you can unzip it and double click to run. 11 | In some Linux systems, you might have to install libxcb-cursor0. 12 | 13 | ## Supported platforms and software 14 | Cat-relay was written in Python3 and can run on Mac, Windows and Linux. 15 | Ready to use executable files are provided for Mac and Windows platforms. 16 | 17 | Currently, Cat-relay mainly supports one SDR software - SDR++ (cross-platform). However, Cat-relay may work with other SDR software if they provide a 18 | Hamlib/Rigctl compatible TCP server. 19 | 20 | Cat-relay supports following radio control software: 21 | * RUMlogNG (Mac) 22 | * Flrig (cross platform) 23 | * DXLab Commander (Windows) 24 | * N1MM+ (Windows) 25 | 26 | Cat-relay may also work with other radio control software if they provide one of two interfaces: 27 | * A DXLab Commander compatible TCP interface. 28 | * A Flrig compatible XML/RPC server. 29 | 30 | At this moment, only SDR++, RUMlogNG, DXLab Commander, Flrig and N1MM+ are 31 | tested. MacLoggerDX may also work, except changing modes on SDR is not supported by MacLoggerDX. 32 | 33 | ## Settings 34 | To change settings, click the "Setting" button on the main window. 35 | 36 | ![Settings](resources/settings.png "Settings") 37 | 38 | Most settings are self-explanatory. Here are some useful tips: 39 | 40 | ### 1. Match the ports 41 | Make sure "SDR Port" matches the port number in SDR++'s "Rigctl Server" panel. 42 | ![SDR++ settings](resources/SDR++-settings.png "SDR++ settings") 43 | 44 | Make sure "CAT Port" matches the port number in your radio control software. 45 | 46 | #### RUMlogNG 47 | ![RUMlogNG settings](resources/RUMlogNG-settings.png "RUMlogNG settings") 48 | #### DXLab Commander 49 | ![Commander settings](resources/DXLab-Commander-settings.png "RUMlogNG settings") 50 | #### Flrig 51 | ![Flrig server config screen](resources/Flrig-server-config-screen.png "Flrig Server config screen") 52 | #### N1MM+ 53 | ![N1MM+ settings](resources/N1MM-settings.png "N1MM settings") 54 | 55 | If you use N1MM+, make sure "Radio Info Port" matches the "radio" port in N1MM+'s "Broadcast Data" configuration page. 56 | For example, if the "radio" box has "127.0.0.1:12060", put "12060" in Cat-relay's "Radio Info Port" box. 57 | 58 | ![N1MM+ Broadcast settings](resources/N1MM-radio-info.png "N1MM+ Broadcast settings") 59 | 60 | ### 2. Put in the correct addresses 61 | If you run all the software on the same computer (most people do), keep "SDR running on" and "CAT running on" as "This computer". 62 | If you run some software on another computer, then make sure you choose "Another computer", and put in the correct IP address. 63 | 64 | ### 3. Reconnect Time 65 | Number of seconds before retry a failed connection. The default (10 seconds) should work fine for most people. 66 | 67 | ### 4. Sync Interval 68 | Number of seconds between Cat-relay syncs your SDR and radio. The default (0.1 seconds) should work fine for most people. 69 | Increase this number if you notice your computer is not very responsive. It is not recommended to set this to less than 0.05. 70 | 71 | ## Run from source code 72 | For Mac and Windows users, please go to [Release Page](https://github.com/n2iw/cat-relay/releases) to download latest version of Cat-relay. 73 | If you are using Linux or you prefer running source code, here are some steps you can follow. 74 | 75 | ### How to install dependencies 76 | Cat-relay uses only one 3rd party libraries (pyyaml). To install it, open a terminal, go to the folder that contains cat-relay, 77 | and run following command: 78 | 79 | ```pip3 install -r requirements.txt``` 80 | 81 | ### How to run source code 82 | Open a terminal, go to the folder that contains cat-relay, and run following command: 83 | 84 | ```python3 app.py``` 85 | 86 | Don't close the terminal window, you can minimize it if you'd like to. 87 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from PySide6.QtCore import Slot 4 | from PySide6.QtWidgets import QVBoxLayout, QDialog, QDialogButtonBox 5 | 6 | from gui_components.dropdown import Dropdown 7 | from gui_components.text_input import TextInput, INTEGER 8 | from gui_components.seconds_input import DecimalSecondsInput, WholeSecondsInput 9 | from config import VALID_CAT_SOFTWARES, PLACEHOLDER_SOFTWARE, VALID_SDRS, SDR_PP, N1MM, VALID_LOCATIONS, LOCAL, NETWORK 10 | 11 | 12 | class Settings(QDialog): 13 | def __init__(self, params, parent=None): 14 | super().__init__(parent) 15 | 16 | # Set up data 17 | self.params = params.copy() 18 | 19 | # Set up UI 20 | self.parent_window = parent 21 | self.radio_info_widget = None 22 | self.sdr_ip_widget = None 23 | self.cat_ip_widget = None 24 | self.setWindowTitle("Settings") 25 | self.setLayout(self.create_layout()) 26 | 27 | 28 | # Connect Signals/Slots 29 | self.params.sdr_location_changed.connect(self.sdr_location_changed) 30 | self.params.cat_location_changed.connect(self.cat_location_changed) 31 | self.params.cat_software_changed.connect(self.cat_software_changed) 32 | 33 | def create_layout(self): 34 | layout = QVBoxLayout() 35 | 36 | # SDR type 37 | layout.addWidget(Dropdown('SDR Software', self.params.sdr_software, VALID_SDRS, SDR_PP, None, disabled=True)) 38 | 39 | # SDR Location 40 | layout.addWidget(Dropdown("SDR running on", self.params.sdr_location, VALID_LOCATIONS, LOCAL, lambda location: self.params.set_sdr_location(location))) 41 | # SDR IP 42 | self.sdr_ip_widget = TextInput("SDR IP", self.params.sdr_ip, lambda ip: self.params.set_sdr_ip(ip), self.params.sdr_location == NETWORK) 43 | layout.addWidget(self.sdr_ip_widget) 44 | # SDR Port 45 | layout.addWidget(TextInput('SDR Port', self.params.sdr_port, lambda port: self.params.set_sdr_port(port), True, INTEGER)) 46 | 47 | # CAT Software 48 | layout.addWidget(Dropdown('Radio Control(CAT) Software', self.params.cat_software, VALID_CAT_SOFTWARES, PLACEHOLDER_SOFTWARE, lambda software: self.params.set_cat_software(software))) 49 | # CAT Location 50 | layout.addWidget(Dropdown("CAT running on", self.params.cat_location, VALID_LOCATIONS, LOCAL, lambda location: self.params.set_cat_location(location))) 51 | # CAT IP 52 | self.cat_ip_widget = TextInput('CAT IP', self.params.cat_ip, lambda ip: self.params.set_cat_ip(ip), self.params.cat_location == NETWORK) 53 | layout.addWidget(self.cat_ip_widget) 54 | # CAT Port 55 | layout.addWidget(TextInput('CAT Port', self.params.cat_port, lambda port: self.params.set_cat_port(port), True, INTEGER)) 56 | 57 | # Radio Info Port (only show when it's N1MM) 58 | self.radio_info_widget = TextInput('Radio Info Port', self.params.radio_info_port, lambda port: self.params.set_radio_info_port(port), self.params.cat_software == N1MM, INTEGER) 59 | layout.addWidget(self.radio_info_widget) 60 | 61 | # Reconnect time 62 | layout.addWidget(WholeSecondsInput('Reconnect Time', self.params.reconnect_time, 3, 60, 1, lambda time: self.params.set_reconnect_time(time))) 63 | # Sync time 64 | layout.addWidget(DecimalSecondsInput('Sync Interval', self.params.sync_interval, 0.05, 5, 0.01, lambda time: self.params.set_sync_interval(time))) 65 | 66 | # Action Buttons 67 | button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) 68 | button_box.accepted.connect(self.accept) 69 | button_box.rejected.connect(self.reject) 70 | layout.addWidget(button_box) 71 | 72 | return layout 73 | 74 | @Slot(str) 75 | def sdr_location_changed(self, location): 76 | print(f'Received SDR Location signal: {location}') 77 | self.sdr_ip_widget.set_visibility(location == NETWORK) 78 | 79 | @Slot(str) 80 | def cat_location_changed(self, location): 81 | print(f'Received CAT Location signal: {location}') 82 | self.cat_ip_widget.set_visibility(location == NETWORK) 83 | 84 | @Slot(str) 85 | def cat_software_changed(self, software): 86 | print(f'Received Cat software signal: {software}') 87 | self.radio_info_widget.set_visibility(software == N1MM) 88 | 89 | def accept(self): 90 | if hasattr(self.parent_window, "update_params"): 91 | if self.params.cat_software == PLACEHOLDER_SOFTWARE: 92 | print('Please select a CAT Software') 93 | return 94 | self.parent_window.update_params(self.params) 95 | else: 96 | print("Unknown parent window, do nothing") 97 | super().accept() 98 | -------------------------------------------------------------------------------- /.github/workflows/build-apps.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Attache Apps 2 | on: 3 | release: 4 | types: [created] 5 | workflow_dispatch: 6 | 7 | jobs: 8 | build-macOS-app-apple-silicon: 9 | runs-on: macos-latest 10 | steps: 11 | - name: Print the branch currently working on 12 | run: echo "BRANCH_NAME=${{ matrix.branch-name }}" 13 | - name: Check out the branch 14 | uses: actions/checkout@v2 15 | with: 16 | submodules: true 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: '3.9' 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install -r requirements.txt 27 | pip install pyinstaller 28 | 29 | - name: Pack source code 30 | run: | 31 | mkdir -p dist 32 | zip -r dist/cat-relay-src.zip . 33 | 34 | - name: Attache source code to release 35 | uses: actions/upload-release-asset@v1 36 | with: 37 | upload_url: ${{ github.event.release.upload_url }} 38 | asset_path: dist/cat-relay-src.zip 39 | asset_name: cat-relay-src.zip 40 | asset_content_type: application/zip 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | 44 | - name: Build macOS App - Apple Silicon 45 | env: 46 | ARCHFLAGS: "-arch arm64" # Apple Silicon 47 | run: | 48 | pyinstaller app.py --name CatRelay --windowed --distpath dist/mac-arm 49 | 50 | - name: Pack macOS app - Apple Silicon 51 | run: | 52 | cd dist/mac-arm 53 | zip -r ../cat-relay-mac-arm.zip CatRelay.app 54 | shell: bash 55 | 56 | - name: Attache macOS App - Apple Silicon to release 57 | uses: actions/upload-release-asset@v1 58 | with: 59 | upload_url: ${{ github.event.release.upload_url }} 60 | asset_path: dist/cat-relay-mac-arm.zip 61 | asset_name: cat-relay-mac-arm.zip 62 | asset_content_type: application/zip 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | 66 | build-macOS-app-intel: 67 | runs-on: macos-13 68 | steps: 69 | - name: Print the branch currently working on 70 | run: echo "BRANCH_NAME=${{ matrix.branch-name }}" 71 | - name: Check out the branch 72 | uses: actions/checkout@v2 73 | with: 74 | submodules: true 75 | 76 | - name: Set up Python 77 | uses: actions/setup-python@v2 78 | with: 79 | python-version: '3.9' 80 | 81 | - name: Install dependencies 82 | run: | 83 | python -m pip install --upgrade pip 84 | pip install -r requirements.txt 85 | pip install pyinstaller 86 | 87 | - name: Build macOS App - Intel 88 | run: | 89 | pyinstaller app.py --name CatRelay --windowed --distpath dist/mac-x64 90 | 91 | - name: Pack macOS app - Intel 92 | run: | 93 | cd dist/mac-x64 94 | zip -r ../cat-relay-mac-x64.zip CatRelay.app 95 | shell: bash 96 | 97 | - name: Attache macOS App - Intel to release 98 | uses: actions/upload-release-asset@v1 99 | with: 100 | upload_url: ${{ github.event.release.upload_url }} 101 | asset_path: dist/cat-relay-mac-x64.zip 102 | asset_name: cat-relay-mac-x64.zip 103 | asset_content_type: application/zip 104 | env: 105 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 106 | 107 | build-windows-app: 108 | runs-on: windows-latest 109 | steps: 110 | - name: Print the branch currently working on 111 | run: echo "BRANCH_NAME=${{ matrix.branch-name }}" 112 | - name: Check out the branch 113 | uses: actions/checkout@v2 114 | with: 115 | submodules: true 116 | - name: Set up Python 117 | uses: actions/setup-python@v2 118 | with: 119 | python-version: '3.9' 120 | 121 | - name: Install dependencies 122 | run: | 123 | python -m pip install --upgrade pip 124 | pip install -r requirements.txt 125 | pip install pyinstaller 126 | 127 | - name: Build Windows App 128 | run: | 129 | pyinstaller app.py --name CatRelay --windowed --distpath dist/windows 130 | 131 | - name: Pack Windows App 132 | run: | 133 | cd dist/windows 134 | Compress-Archive -Path .\* -DestinationPath ../cat-relay-win.zip 135 | 136 | - name: Attach Windows App to release 137 | uses: actions/upload-release-asset@v1 138 | with: 139 | upload_url: ${{ github.event.release.upload_url }} 140 | asset_path: dist/cat-relay-win.zip 141 | asset_name: cat-relay-win.zip 142 | asset_content_type: application/zip 143 | env: 144 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 145 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from PySide6.QtWidgets import QApplication, QMainWindow, QLabel, QVBoxLayout, QWidget, QPushButton, QHBoxLayout, \ 4 | QCheckBox 5 | from PySide6.QtCore import QTimer, Qt, Slot 6 | 7 | from cat_relay import CatRelay, MESSAGE, CHANGED 8 | from config import Config, Parameters 9 | from settings import Settings 10 | 11 | CONNECT = 'Connect' 12 | DISCONNECT = 'Disconnect' 13 | 14 | class MainWindow(QMainWindow): 15 | def __init__(self): 16 | super().__init__() 17 | self.setWindowTitle("Cat Relay") 18 | 19 | # Basic window setup 20 | layout = QVBoxLayout() 21 | main_widget = QWidget() 22 | main_widget.setLayout(layout) 23 | self.setCentralWidget(main_widget) 24 | 25 | # Connect status label 26 | self.connection_label = QLabel("Not connected") 27 | layout.addWidget(self.connection_label) 28 | 29 | # Sync status label 30 | self.sync_label = QLabel("") 31 | layout.addWidget(self.sync_label) 32 | 33 | # Buttons area 34 | button_box = QHBoxLayout() 35 | layout.addLayout(button_box) 36 | 37 | # Auto connect checkbox 38 | auto_connect_button = QCheckBox("Auto Reconnect") 39 | auto_connect_button.checkStateChanged.connect(self.auto_connect_changed) 40 | button_box.addWidget(auto_connect_button) 41 | 42 | # Connect button 43 | self.connect_button = QPushButton(CONNECT) 44 | self.connect_button.setCheckable(True) 45 | self.connect_button.setChecked(True) 46 | self.connect_button.clicked.connect(self.connect_clicked) 47 | button_box.addWidget(self.connect_button) 48 | 49 | # Settings button 50 | setting_button = QPushButton("Settings") 51 | setting_button.clicked.connect(self.open_settings) 52 | button_box.addWidget(setting_button) 53 | 54 | self.config = Config() 55 | self.cat_relay = CatRelay(self.config.params) 56 | self.cat_relay.connection_state_changed.connect(self.cat_relay_connection_changed) 57 | self.timer_id = None 58 | self.auto_connect = False 59 | 60 | # Open settings automatically if no config file found 61 | if not self.config.config_file_full_path: 62 | self.open_settings() 63 | 64 | # Core function 65 | if self.auto_connect: 66 | self.connection_label.setText("Connecting...") 67 | self.connect_cat_relay() 68 | 69 | def connect_clicked(self, checked): 70 | if not checked: 71 | self.connect_cat_relay() 72 | else: 73 | self.disconnect_cat_relay() 74 | 75 | @Slot(bool) 76 | def cat_relay_connection_changed(self, state): 77 | # print(f'CAT Relay connection status: {state}') 78 | # Update button text 79 | if state: 80 | self.connect_button.setText(DISCONNECT) 81 | self.connect_button.setChecked(False) 82 | if not self.timer_id: 83 | self.timer_id = self.startTimer(self.config.params.sync_interval * 1000) 84 | else: 85 | self.connect_button.setText(CONNECT) 86 | self.connect_button.setChecked(True) 87 | 88 | # Stop sync timer 89 | if self.timer_id: 90 | self.killTimer(self.timer_id) 91 | self.timer_id = None 92 | # Reconnect later 93 | if self.auto_connect: 94 | message = 'Connection failed, will try connect later' 95 | print(message) 96 | print('-' * 40) 97 | self.connect_cat_relay_later(message) 98 | 99 | def auto_connect_changed(self, state): 100 | if state == Qt.CheckState.Checked: 101 | self.auto_connect = True 102 | else: 103 | self.auto_connect = False 104 | 105 | def update_params(self, params): 106 | if isinstance(params, Parameters): 107 | print('Parameters updated') 108 | self.config.params = params 109 | self.config.save_to_file() 110 | if self.cat_relay: 111 | self.cat_relay.set_params(params) 112 | else: 113 | print(f'Unknown params object {params}') 114 | 115 | def open_settings(self): 116 | # save previous setting and temporarily disable auto connect 117 | old_auto_connect = self.auto_connect 118 | self.auto_connect = False 119 | 120 | # disconnect before opens setting window 121 | self.disconnect_cat_relay() 122 | settings = Settings(self.config.params, self) 123 | settings.exec() 124 | self.auto_connect = old_auto_connect 125 | if self.auto_connect and not self.connect_button.isChecked(): 126 | self.connect_cat_relay() 127 | 128 | def connect_cat_relay(self): 129 | if self.cat_relay: 130 | result = self.cat_relay.connect_clients() 131 | if result: 132 | self.connection_label.setText("Connected") 133 | else: 134 | self.connection_label.setText("Connection failed") 135 | else: 136 | print("Cat Relay object doesn't exist!") 137 | sys.exit() 138 | 139 | def disconnect_cat_relay(self): 140 | if self.cat_relay: 141 | self.cat_relay.disconnect_clients() 142 | self.connection_label.setText("Disconnected") 143 | 144 | if self.timer_id: 145 | self.killTimer(self.timer_id) 146 | self.timer_id = None 147 | 148 | 149 | def connect_cat_relay_later(self, error): 150 | # Completely disconnect first 151 | if self.cat_relay: 152 | self.cat_relay.disconnect_clients() 153 | message = f'{error}: Retry in {self.config.params.reconnect_time} seconds ...' 154 | self.connection_label.setText(message) 155 | QTimer.singleShot(self.config.params.reconnect_time * 1000, self.connect_cat_relay) 156 | 157 | def timerEvent(self, event): 158 | if self.cat_relay: 159 | result = self.cat_relay.sync() 160 | if result: 161 | if result[CHANGED]: 162 | sync_msg = result[MESSAGE] 163 | if sync_msg != self.sync_label.text(): 164 | self.sync_label.setText(sync_msg) 165 | else: 166 | # Clear last message 167 | if self.sync_label.text() != '': 168 | self.sync_label.setText("") 169 | 170 | 171 | app = QApplication(sys.argv) 172 | main_window = MainWindow() 173 | main_window.show() 174 | app.exec() -------------------------------------------------------------------------------- /cat_relay.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import time 5 | 6 | from PySide6.QtCore import QObject, Signal 7 | 8 | from sdr_control.hamlib import HamLibClient 9 | from radio_control.dxlab import Commander 10 | from radio_control.n1mm import N1MMClient 11 | from radio_control.flrig import FlrigClient 12 | 13 | from config import Config, DXLAB, N1MM, FLRIG, RUMLOG, NETWORK, LOCAL_HOST 14 | 15 | MODE = "mode" 16 | FREQUENCY = "frequency" 17 | DESTINATION = "destination" 18 | SOURCE = "source" 19 | MESSAGE = 'message' 20 | CHANGED = 'changed' 21 | 22 | def sync_result(changed, source = None, destination = None, frequency = None, mode = None): 23 | result = { 24 | CHANGED: changed, 25 | } 26 | if changed: 27 | result.update({ 28 | MESSAGE: f'Sync from {source} to {destination} {frequency}Hz {mode}', 29 | SOURCE: source, 30 | DESTINATION: destination, 31 | FREQUENCY: frequency, 32 | MODE: mode 33 | }) 34 | return result 35 | 36 | 37 | class CatRelay(QObject): 38 | # Signals 39 | connection_state_changed = Signal(bool) 40 | 41 | def __init__(self, params): 42 | super().__init__() 43 | self.cat_location = params.cat_location 44 | self.cat_software = params.cat_software 45 | self.cat_ip = params.cat_ip 46 | self.cat_port = params.cat_port 47 | self.radio_info_port = params.radio_info_port 48 | self.sdr_software = params.sdr_software 49 | self.sdr_location = params.sdr_location 50 | self.sdr_ip = params.sdr_ip 51 | self.sdr_port = params.sdr_port 52 | 53 | self.cat_client = None 54 | self.sdr_client = None 55 | 56 | def set_params(self, params): 57 | self.cat_location = params.cat_location 58 | self.cat_software = params.cat_software 59 | self.cat_ip = params.cat_ip 60 | self.cat_port = params.cat_port 61 | self.radio_info_port = params.radio_info_port 62 | self.sdr_software = params.sdr_software 63 | self.sdr_location = params.sdr_location 64 | self.sdr_ip = params.sdr_ip 65 | self.sdr_port = params.sdr_port 66 | 67 | def is_connected(self): 68 | return self.cat_client is not None and self.sdr_client is not None 69 | 70 | def connect_clients(self): 71 | try: 72 | self.cat_client = self._connect_cat() 73 | print(f'Cat Software connected') 74 | 75 | self.sdr_client = self._connect_sdr() 76 | print(f'SDR connected.') 77 | 78 | except Exception as e: 79 | print(e) 80 | self.disconnect_clients() 81 | 82 | finally: 83 | self.connection_state_changed.emit(self.is_connected()) 84 | return self.is_connected() 85 | 86 | 87 | def disconnect_clients(self): 88 | # save current state 89 | old_state = self.is_connected() 90 | 91 | if self.cat_client: 92 | self.cat_client.close() 93 | self.cat_client = None 94 | print(f'Cat Software disconnected') 95 | 96 | if self.sdr_client: 97 | self.sdr_client.close() 98 | self.sdr_client = None 99 | print(f'SDR disconnected.') 100 | 101 | new_state = self.is_connected() 102 | if old_state != new_state: 103 | self.connection_state_changed.emit(self.is_connected()) 104 | 105 | def __del__(self): 106 | self.disconnect_clients() 107 | 108 | def _connect_sdr(self): 109 | ip_address = self.sdr_ip if self.sdr_location == NETWORK else LOCAL_HOST 110 | print(f'Connecting to {self.sdr_software} at {ip_address}:{self.sdr_port}') 111 | return HamLibClient(ip_address, self.sdr_port).__enter__() 112 | 113 | def _connect_cat(self): 114 | ip_address = self.cat_ip if self.cat_location == NETWORK else LOCAL_HOST 115 | if self.cat_software in [DXLAB, RUMLOG] : 116 | print(f'Connecting to {self.cat_software} at {ip_address}:{self.cat_port}') 117 | return Commander(ip_address, self.cat_port).__enter__() 118 | elif self.cat_software == N1MM: 119 | print(f'Connecting to {self.cat_software} at {ip_address}:{self.cat_port}') 120 | return N1MMClient(self.radio_info_port, ip_address, self.cat_port).__enter__() 121 | elif self.cat_software == FLRIG: 122 | print(f'Connecting to {self.cat_software} at {ip_address}:{self.cat_port}') 123 | return FlrigClient(ip_address, self.cat_port).__enter__() 124 | else: 125 | message = f'Cat software "{self.cat_software}" is not supported!' 126 | print(message) 127 | raise Exception(message) 128 | 129 | def sync(self): 130 | try: 131 | radio_freq = self.cat_client.get_freq() 132 | radio_mode = self.cat_client.get_mode() 133 | if (radio_freq and radio_freq != self.sdr_client.get_last_freq() and isinstance(radio_freq, int)) or \ 134 | (radio_mode and radio_mode != self.sdr_client.get_last_mode() and isinstance(radio_mode, str)): 135 | self.sdr_client.set_freq_mode(radio_freq, radio_mode) 136 | return sync_result(True, 'radio', 'SDR', radio_freq, radio_mode) 137 | else: 138 | sdr_freq = self.sdr_client.get_freq() 139 | sdr_mode = self.sdr_client.get_mode() 140 | if (sdr_freq and sdr_freq != self.cat_client.get_last_freq() and isinstance(sdr_freq, int)) or \ 141 | (sdr_mode and sdr_mode != self.cat_client.get_last_mode() and isinstance(sdr_mode, str)): 142 | self.cat_client.set_freq_mode(sdr_freq, sdr_mode) 143 | return sync_result(True, 'SDR', 'radio', sdr_freq, sdr_mode) 144 | return sync_result(False) 145 | except Exception as e: 146 | print(e) 147 | self.connection_state_changed.emit(self.is_connected()) 148 | return None 149 | 150 | 151 | if __name__ == '__main__': 152 | # reconnect every RETRY_TIME seconds, until user press Ctrl+C 153 | params = Config().params 154 | while True: 155 | try: 156 | cat_relay = CatRelay(params) 157 | cat_relay.connect_clients() 158 | while True: 159 | result = cat_relay.sync() 160 | if result: 161 | if result[CHANGED]: 162 | print(result[MESSAGE]) 163 | else: 164 | print('Sync failed') 165 | time.sleep(params.sync_interval) 166 | 167 | except KeyboardInterrupt as ke: 168 | print("\nTerminated by user.") 169 | cat_relay = None 170 | sys.exit() 171 | except Exception as e: 172 | retry_time = params.reconnect_time 173 | print(e) 174 | print(f'Retry in {retry_time} seconds ...') 175 | print('Press Ctrl+C to exit') 176 | time.sleep(retry_time) 177 | 178 | 179 | 180 | 181 | 182 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import yaml 3 | from PySide6.QtCore import QObject, Signal 4 | from pathlib import Path 5 | 6 | SDR_PP = 'SDR++' 7 | VALID_SDRS = [SDR_PP] 8 | SDR_SOFTWARE = 'SDR_SOFTWARE' 9 | SDR_LOCATION = 'SDR_LOCATION' 10 | SDR_IP = 'SDR_IP' 11 | SDR_PORT = 'SDR_PORT' 12 | 13 | CAT_LOCATION = 'CAT_LOCATION' 14 | CAT_IP = 'CAT_IP' 15 | CAT_PORT = 'CAT_PORT' 16 | 17 | RADIO_INFO_PORT = 'RADIO_INFO_PORT' 18 | 19 | RECONNECT_TIME = 'RECONNECT_TIME' # seconds 20 | SYNC_INTERVAL = 'SYNC_TIME' # seconds 21 | 22 | CAT_SOFTWARE = 'CAT_Software' 23 | PLACEHOLDER_SOFTWARE = '--select your software--' 24 | DXLAB = 'DXLab' 25 | RUMLOG = 'RUMLogNG' 26 | N1MM = 'N1MM' 27 | FLRIG = 'FLRIG' 28 | VALID_CAT_SOFTWARES = [DXLAB, RUMLOG, N1MM, FLRIG] 29 | 30 | CONFIG_FILE = 'cat-relay.yml' 31 | 32 | LOCAL = 'This computer' 33 | NETWORK = 'Another computer' 34 | VALID_LOCATIONS = [LOCAL, NETWORK] 35 | 36 | LOCAL_HOST = '127.0.0.1' 37 | 38 | class Parameters(QObject): 39 | # Qt Signals 40 | sdr_location_changed = Signal(str) 41 | cat_software_changed = Signal(str) 42 | cat_location_changed = Signal(str) 43 | 44 | def __init__(self, 45 | sdr_software=SDR_PP, 46 | sdr_location=LOCAL, 47 | sdr_ip=LOCAL_HOST, 48 | sdr_port=4532, 49 | cat_location=LOCAL, 50 | cat_software=RUMLOG, 51 | cat_ip=LOCAL_HOST, 52 | cat_port=5555, 53 | radio_info_port=13063, 54 | reconnect_time=10, 55 | sync_interval=0.1 56 | ): 57 | super().__init__(None) 58 | self.sdr_software = sdr_software 59 | self.sdr_location = sdr_location 60 | self.sdr_ip = sdr_ip 61 | self.sdr_port = sdr_port 62 | self.cat_location = cat_location 63 | self.cat_software = cat_software 64 | self.cat_ip = cat_ip 65 | self.cat_port = cat_port 66 | self.radio_info_port = radio_info_port 67 | self.reconnect_time = reconnect_time 68 | self.sync_interval = sync_interval 69 | 70 | def copy(self): 71 | return Parameters( 72 | self.sdr_software, 73 | self.sdr_location, 74 | self.sdr_ip, 75 | self.sdr_port, 76 | self.cat_location, 77 | self.cat_software, 78 | self.cat_ip, 79 | self.cat_port, 80 | self.radio_info_port, 81 | self.reconnect_time, 82 | self.sync_interval 83 | ) 84 | 85 | def set_sdr_location(self, location): 86 | if self.sdr_location != location: 87 | self.sdr_location = location 88 | self.sdr_location_changed.emit(location) 89 | 90 | def set_cat_location(self, location): 91 | if self.cat_location != location: 92 | self.cat_location = location 93 | self.cat_location_changed.emit(location) 94 | 95 | def set_sdr_ip(self, ip): 96 | self.sdr_ip = ip 97 | 98 | def set_sdr_port(self, port): 99 | self.sdr_port = port 100 | 101 | def set_cat_software(self, software): 102 | if self.cat_software != software: 103 | self.cat_software = software 104 | self.cat_software_changed.emit(software) 105 | 106 | def set_cat_ip(self, ip): 107 | self.cat_ip = ip 108 | 109 | def set_cat_port(self, port): 110 | self.cat_port = port 111 | 112 | def set_radio_info_port(self, port): 113 | self.radio_info_port = port 114 | 115 | def set_reconnect_time(self, seconds): 116 | self.reconnect_time = seconds 117 | 118 | def set_sync_interval(self, seconds): 119 | self.sync_interval = seconds 120 | 121 | 122 | class Config: 123 | def __init__(self): 124 | self.params = Parameters() 125 | 126 | script_dir = os.path.dirname(os.path.realpath(__file__)) 127 | work_dir = os.getcwd() 128 | home_dir = Path.home() 129 | config_file_locations = [work_dir, home_dir, script_dir] 130 | self.config_file_full_path = None 131 | self.default_config_file_full_path = os.path.join(home_dir, CONFIG_FILE) 132 | for location in config_file_locations: 133 | config_file_full_path = os.path.join(location, CONFIG_FILE) 134 | if os.path.isfile(config_file_full_path): 135 | try: 136 | print(f'Config file {config_file_full_path} found, reading configuration...') 137 | with open(config_file_full_path) as c_file: 138 | file_config = yaml.safe_load(c_file) 139 | self.update_params_from(file_config) 140 | self.config_file_full_path = config_file_full_path 141 | break 142 | except Exception as e: 143 | print(e) 144 | 145 | def update_params_from(self, params_dict): 146 | self.params.sdr_software = params_dict.get(SDR_SOFTWARE, self.params.sdr_software) 147 | self.params.sdr_location = params_dict.get(SDR_LOCATION, self.params.sdr_location) 148 | self.params.sdr_ip = params_dict.get(SDR_IP, self.params.sdr_ip) 149 | self.params.sdr_port = params_dict.get(SDR_PORT, self.params.sdr_port) 150 | self.params.cat_software = params_dict.get(CAT_SOFTWARE, self.params.cat_software) 151 | self.params.cat_location = params_dict.get(CAT_LOCATION, self.params.cat_location) 152 | self.params.cat_ip = params_dict.get(CAT_IP, self.params.cat_ip) 153 | self.params.cat_port = params_dict.get(CAT_PORT, self.params.cat_port) 154 | self.params.radio_info_port = params_dict.get(RADIO_INFO_PORT, self.params.radio_info_port) 155 | self.params.reconnect_time = params_dict.get(RECONNECT_TIME, self.params.reconnect_time) 156 | self.params.sync_interval = params_dict.get(SYNC_INTERVAL, self.params.sync_interval) 157 | 158 | def get_data(self): 159 | return { 160 | SDR_SOFTWARE: self.params.sdr_software, 161 | SDR_LOCATION: self.params.sdr_location, 162 | SDR_IP: self.params.sdr_ip, 163 | SDR_PORT: self.params.sdr_port, 164 | CAT_LOCATION: self.params.cat_location, 165 | CAT_SOFTWARE: self.params.cat_software, 166 | CAT_IP: self.params.cat_ip, 167 | CAT_PORT: self.params.cat_port, 168 | RADIO_INFO_PORT: self.params.radio_info_port, 169 | RECONNECT_TIME: self.params.reconnect_time, 170 | SYNC_INTERVAL: self.params.sync_interval 171 | } 172 | 173 | def save_to_file(self): 174 | config_file_path = None 175 | # Updating existing config file 176 | if self.config_file_full_path and os.path.isfile(self.config_file_full_path): 177 | config_file_path = self.config_file_full_path 178 | print(f'Updating config file at {config_file_path}') 179 | else: # create a new config file in HOME folder 180 | print(f'Creating new config file at {config_file_path}') 181 | config_file_path = self.default_config_file_full_path 182 | 183 | with open(config_file_path, 'w') as c_file: 184 | yaml_str = yaml.dump(self.get_data()) 185 | c_file.write(yaml_str) 186 | 187 | --------------------------------------------------------------------------------