Управляем Gqrx при помощи Elecraft KX3
При работе на спутнике QO-100 я обратил внимание на одно неудобство. Частота приема и частота передачи независимы. Проявив невнимательность, можно начать передавать не не той частоте, на которой хотелось. Вот бы эти частоты синхронизировать.
Напомню, из чего состоит моя радиостанция. В роли приемника выступают LimeSDR и Gqrx. На передачу применены Elecraft KX3 с самодельным апконвертером.
Чтобы частоты приемника и передатчика совпадали, один из них должен служить источником правды. То есть, либо Gqrx должен корректировать частоту KX3, либо наоборот. Было бы еще здорово выключать приемник во время передачи. Из двух источников правды только KX3 знает, передаем мы или принимаем. Соответственно, его и назначаем главным.
Как автоматизировать взаимодействие с КВ-трансивером нам известно. В современных аппаратах для этого предусмотрен CAT-интерфейс. Конкретно у KX3 он выведен на разъем ACC1. В качестве адаптера USB-UART выступает KXUSB. Данный кабель входит в базовую поставку, поскольку с его же помощью происходит обновление прошивки. Скорость UART устанавливается в меню трансивера с названием «RS232».
Доступные команды описаны в документе Elecraft K3S / K3 / KX3 / KX2 Programmer's Reference [PDF]. Их много, но нам нужны лишь две. Команда IF; возвращает информацию о текущей частоте, модуляции, состоянии расстройки и так далее. В ответ на TQ; трансивер отвечает TQ0; когда работает на прием и TQ1; в противном случае.
Как управлять Gqrx по TCP мы тоже знаем (раз, два). Остальное – дело техники:
#!/usr/bin/env python3
# kx3-cat.py (c) Alex R2AUK 2026
# https://eax.me/2026/2026-11-11-elecraft-kx3-cat-interface.html
import argparse
import socket
import sys
import time
import serial
MODES = {"2": "USB", "3": "CW"}
KX3_START_FREQ = 29_000_000
GQRX_START_FREQ = 10_489_500_000
BANDWIDTH = 500_000
class GqrxClient:
def __init__(self, host, port):
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.connect((host, port))
self._file = self._sock.makefile("r")
def _send(self, command):
self._sock.sendall((command + "\r\n").encode("ascii"))
def _recv(self):
return self._file.readline().strip()
def _get_rprt(self):
resp = self._recv()
parts = resp.split()
if len(parts) != 2 or parts[0] != "RPRT":
raise Exception(f"Unexpected response: {resp}")
code = int(parts[1])
if code != 0:
raise Exception(f"Command failed with RPRT {code}")
return code
def set_mute(self, mute):
self._send(f"U MUTE {1 if mute else 0}")
return self._get_rprt()
def set_mode(self, mode):
self._send(f"M {mode}")
return self._get_rprt()
def get_hw_freq(self):
self._send("gqrx_get_hw_freq")
return int(self._recv())
def get_rx_freq(self):
self._send("gqrx_get_rx_freq")
return int(self._recv())
def set_rx_freq(self, freq):
self._send(f"gqrx_set_rx_freq {int(freq)}")
return self._get_rprt()
class CatClient:
def __init__(self, device, baudrate):
self._conn = serial.Serial(device, baudrate, timeout=1)
def _query(self, command):
self._conn.reset_input_buffer()
self._conn.write(command.encode("ascii"))
self._conn.flush()
resp = self._conn.read_until(b";").decode("ascii", "replace")
if not resp.endswith(";"):
raise Exception(f"Timed out waiting for response to {command}")
return resp[:-1]
def get_info(self):
resp = self._query("IF;")
if not resp.startswith("IF") or len(resp) < 36:
raise Exception(f"Unexpected IF response: {resp}")
freq = int(resp[2:13])
rit = int(resp[18] + resp[19:23]) if resp[23] == "1" else 0
mode = MODES.get(resp[29], "OTHER")
return freq, rit, mode
def get_state(self):
resp = self._query("TQ;")
if resp not in ("TQ0", "TQ1"):
raise Exception(f"Unexpected TQ response: {resp}")
return "TX" if resp == "TQ1" else "RX"
parser = argparse.ArgumentParser(
description="Elecraft KX3 CAT <-> Gqrx bridge"
)
parser.add_argument(
"--device",
default="/dev/ttyUSB0",
help="Serial port device (default: /dev/ttyUSB0)")
parser.add_argument(
"--baudrate",
type=int,
default=9600,
help="Baud rate (default: 9600)")
parser.add_argument(
"--interval",
type=int,
default=50,
help="Interval between updates in milliseconds (default: 50)")
parser.add_argument(
"--host",
default="127.0.0.1",
help="Gqrx host (default: 127.0.0.1)")
parser.add_argument(
"--port",
type=int,
default=7356,
help="Gqrx port (default: 7356)")
args = parser.parse_args()
gqrx = GqrxClient(args.host, args.port)
cat = CatClient(args.device, args.baudrate)
def sync_freq(kx3_freq):
target = GQRX_START_FREQ + kx3_freq - KX3_START_FREQ
rx_freq = target - gqrx.get_hw_freq()
gqrx.set_rx_freq(rx_freq)
if gqrx.get_rx_freq() != rx_freq:
print(f"Gqrx refused RX offset {rx_freq} Hz", file=sys.stderr)
prev = None
prev_state = None
prev_mode = None
prev_freq = None
while True:
try:
freq, rit, mode = cat.get_info()
state = cat.get_state()
except Exception as e:
print(e, file=sys.stderr)
time.sleep(1)
continue
if state != prev_state:
gqrx.set_mute(state == "TX")
prev_state = state
if mode != prev_mode and mode != "OTHER":
gqrx.set_mode(mode)
prev_mode = mode
if freq + rit != prev_freq:
prev_freq = freq + rit
if KX3_START_FREQ <= prev_freq <= KX3_START_FREQ + BANDWIDTH:
sync_freq(prev_freq)
line = f"{freq} {rit} {mode} {state}"
if line != prev:
print(line)
prev = line
time.sleep(args.interval / 1000)
Скрипту нужен пропатченный Gqrx, поскольку мой pull request с необходимыми командами еще на рассмотрении. Вспоминаем, что есть еще один скрипт, который занимается коррекцией частоты приема по маяку. Были опасения, что программа не примет два TCP-соединения одновременно, однако все обошлось.
Теперь если я кручу валкодер KX3, то частота в Gqrx меняется сама. Также в KX3 можно переключаться между CW и SSB, крутить расстройку и переходить между сохраненными частотами. Gqrx обновляет свое состояние автоматически. При переходе на передачу приемник делает mute. Проводить радиосвязи стало намного удобней.