hamlib_server.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
r"""
hamlib_server.py — rigctld emulator (Hamlib NET rigctl) — broadcast virtual rig.
Architecture, Option C: N clients can connect simultaneously to the same port.
- Read commands (GET_FREQ, GET_MODE, GET_PTT etc.) → answered for every client
- Write commands (SET_FREQ, SET_MODE, SET_PTT etc.) → only when no one holds
the radio, or when the radio isn't locked. If locked → RPRT 0 (fake
success, does nothing) — programs like WSJT-X don't disconnect on RPRT 0.
WSJT-X's startup sequence (Hamlib 4.x):
1. \chk_rig → "RPRT 0\n"
2. \dump_caps → a "setting=value\n...\nRPRT 0\n" block
3. \dump_state → lines with the radio's capabilities
4. \get_vfo → "VFOA\nRPRT 0\n"
5. \get_freq → "14074000\nRPRT 0\n"
etc.
"""
import asyncio
DEFAULT_PORTS = [4532, 4533, 4534]
MODE_TO_HAMLIB = {
"USB":"USB","LSB":"LSB","AM":"AM","FM":"FM","WFM":"WFM",
"CW":"CW","CW-R":"CWR","RTTY":"RTTY","RTTY-R":"RTTYR",
"USB-D":"PKTUSB","LSB-D":"PKTLSB",
"PKTUSB":"PKTUSB","PKTLSB":"PKTLSB",
}
MODE_FROM_HAMLIB = {
"USB":"USB","LSB":"LSB","AM":"AM","FM":"FM","WFM":"WFM",
"CW":"CW","CWR":"CW-R","RTTY":"RTTY","RTTYR":"RTTY-R",
"PKTUSB":"USB-D","PKTLSB":"LSB-D",
}
class HamlibSession:
def __init__(self, rig, hub, reader, writer, slot_name, app=None):
self.rig = rig
self.hub = hub
# Reference to the app — needed to check radio_lock. Without it the
# session wouldn't know who holds the TRX (the hub doesn't have
# that), so the network control lock couldn't work.
self.app = app
self.reader = reader
self.writer = writer
self.addr = writer.get_extra_info('peername')
self.slot = slot_name
self._running = True
async def run(self):
print(f"[hamlib:{self.slot}] Connected: {self.addr}", flush=True)
try:
# IMPORTANT: the netrigctl protocol does NOT send any banner
# after connecting. The client (Hamlib) ALWAYS sends a command
# first. This used to incorrectly send b'0\n' here, which
# shifted the WHOLE stream by one line and broke parsing of
# every subsequent response.
while self._running:
line = await asyncio.wait_for(self.reader.readline(), timeout=60.0)
if not line:
break
cmd = line.decode(errors='replace').strip()
if not cmd:
continue
print(f"[hamlib:{self.slot}] << {cmd!r}", flush=True)
resp = await self._handle(cmd)
out = (resp if resp.endswith('\n') else resp + '\n').encode()
print(f"[hamlib:{self.slot}] >> {resp[:80]!r}", flush=True)
self.writer.write(out)
await self.writer.drain()
except (asyncio.TimeoutError, asyncio.IncompleteReadError,
ConnectionResetError, BrokenPipeError):
pass
except Exception as e:
import traceback; traceback.print_exc()
finally:
print(f"[hamlib:{self.slot}] Disconnected: {self.addr}", flush=True)
try: self.writer.close()
except: pass
def _can_control(self) -> bool:
"""
Are CONTROL commands (changing the radio's state) allowed?
RULE (same as in the webapp and on COM ports): the radio is
controlled ONLY by the logged-in operator who has TAKEN the TRX
(radio_lock). rigctl ports are open to the internet and the
protocol has NO authentication, so without this any scanner could
send 'T 1' and leave the radio transmitting.
Allowed when:
- the radio is in SIM mode (testing), or
- SOMEONE holds radio_lock — meaning an operator logged into the
webapp and took the TRX; their external software (WSJT-X,
logger) is running.
Blocked when no one holds the lock — in that case incoming control
commands are either a mistake or unrelated network traffic.
NOTE: this is NOT the inverse of the old "block when someone holds
the lock" logic (which protected against a collision between two
sources). There's no collision here, because the operator holding
the lock is the SAME person using their own software.
READ commands (GET_FREQ, GET_MODE, dump_caps...) always work —
read-only access doesn't hurt anyone and doesn't break radio
detection by external software.
"""
if self.rig.sim:
return True
app = (self.app
or getattr(self.hub, 'app', None)
or getattr(self.hub, '_app', None))
if app is None:
return False # no reference to app — SAFELY deny control
lock = getattr(app, 'radio_lock', {})
return lock.get('user_id') is not None
async def _handle(self, cmd: str) -> str:
raw = cmd.strip()
parts = raw.split()
if not parts:
return 'RPRT 0'
# Hamlib 4.x: \komenda → strip backslash, lowercase
c = parts[0].lstrip('\\').upper()
args = parts[1:]
try:
# ── Ping / check ───────────────────────────────────────────────────
if c in ('CHKRIG', 'CHK_RIG', 'CHECKRIG'):
return 'RPRT 0'
# ── dump_caps — setting=value format (WSJT-X checks this first) ────
if c in ('DUMP_CAPS', 'DUMPCAPS'):
return self._dump_caps()
# ── dump_state — numeric format ─────────────────────────────────────
if c in ('DUMP_STATE', 'DUMPSTATE'):
return self._dump_state()
# ── Frequency ──────────────────────────────────────────────────────
if c in ('F', 'GET_FREQ', 'GETFREQ'):
if not args:
return f"{int(self.rig.freq)}\nRPRT 0"
if c in ('F', 'SET_FREQ', 'SETFREQ') and args:
if not self._can_control():
return 'RPRT 0' # radio busy — fake success, do nothing
hz = int(float(args[0]))
self.rig.freq = hz
if not self.rig.sim:
try: await self.rig.set_freq(hz)
except: pass
await self.hub.broadcast({"type":"freq","freq":hz,"src":"hamlib"})
print(f"[hamlib:{self.slot}] freq={hz/1e6:.6f}MHz", flush=True)
return 'RPRT 0'
# ── Lock mode (WSJT-X 2.6+) ───────────────────────────────────────
if c in ('GET_LOCK_MODE', 'LOCK_MODE'):
return "0\nRPRT 0"
if c in ('SET_LOCK_MODE',) and args:
return 'RPRT 0'
# ── Mode ───────────────────────────────────────────────────────────
if c in ('M', 'GET_MODE', 'GETMODE') and not args:
hm = MODE_TO_HAMLIB.get(self.rig.mode, self.rig.mode)
bw = getattr(self.rig, 'bw', 0) or 0
# Hamlib requires bw >= 0, -1 means "default"
return f"{hm}\n{max(0, bw)}\nRPRT 0"
if c in ('M', 'SET_MODE', 'SETMODE') and args:
if not self._can_control():
return 'RPRT 0'
hmode = args[0].upper()
bw = int(args[1]) if len(args) >= 2 else 0
mode = MODE_FROM_HAMLIB.get(hmode, hmode)
self.rig.mode = mode
if bw: self.rig.bw = bw
if not self.rig.sim:
try:
await self.rig.set_mode(mode, bw)
except Exception as e:
print(f"[hamlib] set_mode ERROR for mode={mode!r}: {e!r}")
await self.hub.broadcast({"type":"mode","mode":mode,"bandwidth":bw,"src":"hamlib"})
return 'RPRT 0'
# ── PTT ────────────────────────────────────────────────────────────
if c in ('T', 'GET_PTT', 'GETPTT') and not args:
return f"{'1' if self.rig.ptt else '0'}\nRPRT 0"
if c in ('T', 'SET_PTT', 'SETPTT') and args:
if not self._can_control():
return 'RPRT 0'
# Hamlib PTT values: 0=OFF, 1=ON, 2=ON_MIC, 3=ON_DATA.
# JTDX/WSJT-X in digital mode (USB-D/PKTUSB) sends '3'
# (RIG_PTT_ON_DATA) instead of a plain '1' — any nonzero
# value means TX.
raw_val = args[0]
if raw_val.lstrip('-').isdigit():
on = int(raw_val) != 0
else:
on = raw_val in ('true', 'True', 'on', 'TX')
print(f"[hamlib:{self.slot}] PTT SET -> {'TX' if on else 'RX'} (sim={self.rig.sim}, ser={self.rig._ser is not None})", flush=True)
self.rig.ptt = on
if not self.rig.sim:
if self.rig._ser:
try:
await self.rig.set_ptt(on)
print(f"[hamlib:{self.slot}] PTT CI-V OK", flush=True)
except Exception as e:
print(f"[hamlib:{self.slot}] PTT CI-V ERROR: {e}", flush=True)
else:
print(f"[hamlib:{self.slot}] PTT: no serial port!", flush=True)
await self.hub.broadcast({"type":"ptt","ptt":on,"src":"hamlib"})
return 'RPRT 0'
# ── VFO ────────────────────────────────────────────────────────────
if c in ('V', 'GET_VFO', 'GETVFO') and not args:
return "VFOA\nRPRT 0"
if c in ('V', 'SET_VFO', 'SETVFO') and args:
return 'RPRT 0'
# ── Split ──────────────────────────────────────────────────────────
if c in ('S', 'GET_SPLIT_VFO') and not args:
split = getattr(self.rig, 'split', False)
return f"{'1' if split else '0'}\nVFOB\nRPRT 0"
if c in ('S', 'SET_SPLIT_VFO') and args:
if not self._can_control():
return 'RPRT 0'
on = args[0] in ('1','true')
self.rig.split = on
if not self.rig.sim:
try: await self.rig.set_split(on)
except: pass
await self.hub.broadcast({"type":"split","split":on,"src":"hamlib"})
return 'RPRT 0'
# ── Levels ─────────────────────────────────────────────────────────
if c in ('L', 'GET_LEVEL') and args:
lname = args[0].upper()
if lname == 'STRENGTH':
sm = getattr(self.rig, 's_meter', 0) or 0
db = (sm - 9) * 6 if sm <= 9 else (sm - 9) * 10
return f"{db:.1f}\nRPRT 0"
if lname == 'RFPOWER':
pwr = getattr(self.rig, 'rf_power', 100) or 100
return f"{pwr/100.0:.4f}\nRPRT 0"
return "0.0000\nRPRT 0"
if c in ('L', 'SET_LEVEL') and len(args) >= 2:
return 'RPRT 0'
# ── DCD / RIT / XIT ────────────────────────────────────────────────
if c in ('D', 'GET_DCD', 'GETDCD'):
return "0\nRPRT 0"
if c in ('Z', 'GET_RIT', 'GETRIT', 'GET_XIT', 'GETXIT'):
return "0\nRPRT 0"
if c in ('Z', 'SET_RIT', 'SETRIT', 'SET_XIT', 'SETXIT') and args:
return 'RPRT 0'
# ── TX Freq ────────────────────────────────────────────────────────
if c in ('I', 'GET_SPLIT_FREQ'):
return f"{int(self.rig.freq)}\nRPRT 0"
if c in ('I', 'SET_SPLIT_FREQ') and args:
return 'RPRT 0'
# ── TX Mode ────────────────────────────────────────────────────────
if c in ('X', 'GET_SPLIT_MODE'):
hm = MODE_TO_HAMLIB.get(self.rig.mode, self.rig.mode)
bw = getattr(self.rig, 'bw', 0) or 0
return f"{hm}\n{bw}\nRPRT 0"
if c in ('X', 'SET_SPLIT_MODE') and args:
return 'RPRT 0'
# ── Antennas ───────────────────────────────────────────────────────
if c in ('Y', 'GET_ANT', 'GETANT'):
return "1\nRPRT 0"
if c in ('Y', 'SET_ANT', 'SETANT') and args:
return 'RPRT 0'
# ── Info ───────────────────────────────────────────────────────────
if c in ('_', 'GET_INFO', 'GETINFO'):
name = getattr(self.rig, '_rig_name', 'Ham Radio Control')
return f"Info: {name}\nRPRT 0"
# ── Quit ───────────────────────────────────────────────────────────
if c == 'Q':
self._running = False
return 'RPRT 0'
# ── Unknown command ──────────────────────────────────────────────────
# Internet scanners knock with TLS (ClientHello starts with
# 0x16 0x03) and used to flood the log with hundreds of UNKNOWN
# lines full of binary junk. Such a connection is NOT a rigctl
# client — we close it quietly, without logging every packet.
_binary_junk = any(ch < 32 and ch not in (9, 10, 13)
for ch in raw.encode("utf-8", "ignore")[:8])
if _binary_junk:
self._running = False # disconnect — this isn't rigctl
if not getattr(self, "_junk_logged", False):
self._junk_logged = True
print(f"[hamlib:{self.slot}] Rejected a non-rigctl "
f"connection (binary data — scanner/TLS)", flush=True)
return 'RPRT -1'
print(f"[hamlib:{self.slot}] UNKNOWN: {raw!r}", flush=True)
return 'RPRT 0'
except Exception as e:
print(f"[hamlib:{self.slot}] ERR '{raw}': {e}", flush=True)
return 'RPRT -1'
def _dump_caps(self) -> str:
"""
DUMP_CAPS — the "setting=value" format required by WSJT-X / Hamlib 4.x.
Each line is a key=value pair. The block ends with "RPRT 0".
Source: hamlib/src/rig.c dump_caps_helper()
"""
name = getattr(self.rig, '_rig_name', 'Ham Radio Control Server')
freq = int(self.rig.freq)
lines = [
'Caps dump for model: 2',
'Model name:\t\tNET rigctl',
'Mfg name:\t\tHamlib',
'Backend version:\t4.5',
'Backend copyright:\tLGPL',
'Backend status:\tBeta',
'Rig type:\tOther',
'PTT type:\tCAT',
'DCD type:\tNone',
'Port type:\tNetwork',
'Write delay:\t0mS, timeout 0mS, 3 retr',
'Post write delay:\t0mS',
'Has targetable VFO:\tY',
'Has transceive:\tN',
'Announce:\t0x0',
'Max RIT:\t0 Hz',
'Max XIT:\t0 Hz',
'Max IF-SHIFT:\t0 Hz',
'Preamp step:\t0 dB',
'ATT step:\t0 dB',
'Get functions:\t',
'Set functions:\t',
'Get level: STRENGTH(0..1) RFPOWER(0..1) ',
'Set level: RFPOWER(0..1) ',
'Get parm: ',
'Set parm: ',
'Mode list:\t USB LSB AM FM CW CWR RTTY RTTYR PKTUSB PKTLSB',
'VFO list:\t VFOA VFOB',
'VFO ops:\t',
'Scan ops:\t',
'Number of banks:\t0',
'Memory name desc size:\t0',
'Memories:',
'TX ranges #1 for ITU region 1:',
'RX ranges #1 for ITU region 1:',
f' {freq} Hz .. 30000000 Hz, modes: USB LSB AM FM CW RTTY PKTUSB PKTLSB, Low power: -1 W, High power: -1 W, Antenna: 1',
f'TX ranges #1 for ITU region 2:',
f'RX ranges #1 for ITU region 2:',
f'Tuning steps:',
f' * 1 Hz USB LSB AM FM CW RTTY PKTUSB PKTLSB',
f'Filters:',
f' 500 Hz CW CWR RTTY RTTYR',
f' 2400 Hz USB LSB PKTUSB PKTLSB',
f' 3000 Hz AM',
f' 15000 Hz FM',
f'Best frequency resolution = 1 Hz',
f'Has priv data:\tN',
'RPRT 0',
]
return '\n'.join(lines)
def _dump_state(self) -> str:
"""
DUMP_STATE — format matching hamlib/rigs/dummy/netrigctl.c's netrigctl_open() exactly.
Order verified against the source (Hamlib master):
1. prot_ver = atoi(line1) # "0"
2. read_string() — line2 IS READ but UNUSED (ignored)
3. read_string() — line3 = ITU region -> atoi()
4. loop of HAMLIB_FRQRANGESIZ RX freq range lines (ends with sentinel 0 0 0 0 0 0 0)
5. loop of HAMLIB_FRQRANGESIZ TX freq range lines (ends with sentinel)
6. tuning steps loop (ends with "0 0")
7. filters loop (ends with "0 0")
8. max_rit, max_xit, max_ifshift
9. announces (count)
10. preamp list (space-separated numbers, ends with 0)
11. attenuator list (same)
12. has_get_func, has_set_func (hex)
13. has_get_level, has_set_level (hex)
14. has_get_parm, has_set_parm (hex)
"""
lines = [
'0', # 1. prot_ver
'2', # 2. (read, ignored by the client)
'1', # 3. ITU region
# 4. RX freq ranges — each line: start end modemask low high vfo ant
'1800000 30000000 0x1ff -1 -1 0x3 0',
'50000000 54000000 0x1ff -1 -1 0x3 0',
'0 0 0 0 0 0 0', # sentinel ending RX
# 5. TX freq ranges
'1800000 30000000 0x1ff -1 -1 0x3 0',
'50000000 54000000 0x1ff -1 -1 0x3 0',
'0 0 0 0 0 0 0', # sentinel ending TX
# 6. Tuning steps: mode step
'0x1ff 1',
'0 0', # sentinel
# 7. Filters: mode width
'0x1ff 500',
'0x1ff 2400',
'0x1ff 3000',
'0 0', # sentinel
# 8. max_rit max_xit max_ifshift (3 separate lines)
'0',
'0',
'0',
# 9. announces
'0',
# 10. preamp list (ends with 0)
'0',
# 11. attenuator list (ends with 0)
'0',
# 12. has_get_func has_set_func
'0',
'0',
# 13. has_get_level has_set_level
'0x40000003',
'0x40000003',
# 14. has_get_parm has_set_parm
'0',
'0',
'RPRT 0',
]
return '\n'.join(lines)
# ── VirtualRigServer and HamlibManager ───────────────────────────────────────
class VirtualRigServer:
def __init__(self, port, slot_idx, app):
self.port = port
self.slot_idx = slot_idx
self.app = app
self.name = f"Radio {slot_idx+1} ({port})"
self._server = None
self._sessions = []
self.running = False
self.connected_clients = 0
@property
def rig(self):
return self.app.rig
async def start(self):
try:
# rigctl ports are DELIBERATELY open to the network — club users
# connect from external networks with their own software
# (WSJT-X, loggers).
# SECURITY NOTE: the rigctl protocol has no authentication, so
# protection has to live at a higher layer (a firewall with an
# address list, VPN, or server-side authorization — see the
# TODO below). Anyone using it only locally can narrow this in
# config.json:
# "hamlib_bind": "127.0.0.1"
_bind = "0.0.0.0"
try:
_cfg = getattr(self.app, "cfg", {}) or {}
_bind = str(_cfg.get("hamlib_bind", "0.0.0.0")).strip() or "0.0.0.0"
except Exception:
pass
# NOTE: family=0 (AF_UNSPEC) together with host='0.0.0.0' is an
# ambiguous combination on Windows — it can cause a silent
# binding failure. We use plain IPv4 (verified working via
# telnet/PowerShell).
self._server = await asyncio.start_server(
self._handle_connection, _bind, self.port,
)
self.running = True
sockets_info = [s.getsockname() for s in self._server.sockets]
print(f"[hamlib] {self.name}: TCP listening on {sockets_info}", flush=True)
task = asyncio.create_task(self._serve())
task.add_done_callback(self._on_serve_done)
except OSError as e:
print(f"[hamlib] {self.name}: BIND ERROR on port {self.port}: {e}", flush=True)
self.running = False
def _on_serve_done(self, task):
"""Log if serve_forever() ends unexpectedly (a silent crash)."""
if task.cancelled():
return
exc = task.exception()
if exc:
print(f"[hamlib] {self.name}: SERVER CRASHED: {type(exc).__name__}: {exc}", flush=True)
import traceback
traceback.print_exception(type(exc), exc, exc.__traceback__)
else:
print(f"[hamlib] {self.name}: serve_forever() ended without an error (unexpected)", flush=True)
self.running = False
async def _serve(self):
async with self._server:
await self._server.serve_forever()
async def stop(self):
if self._server:
self._server.close()
try: await self._server.wait_closed()
except: pass
self.running = False
async def _handle_connection(self, reader, writer):
self.connected_clients += 1
s = HamlibSession(self.rig, self.app.hub, reader, writer, self.name,
app=self.app)
self._sessions.append(s)
try:
await s.run()
finally:
self._sessions.remove(s)
self.connected_clients -= 1
def status(self):
return {
"slot": self.slot_idx, "name": self.name,
"port": self.port, "running": self.running,
"clients": self.connected_clients,
}
class HamlibManager:
def __init__(self, app):
self.app = app
self.servers = []
def _get_config(self):
# Option C: one broadcast port for all users. Everyone connects on
# port 4532 — N clients simultaneously. The extra ports (4533,
# 4534) are optional for special uses (e.g. a separate read-only
# skimmer with no control).
# NOTE: "label" values below are UI text (admin panel, see
# public/js/hamlib_ui.js - displayed raw, no I18n, and editable by
# the admin) - deliberately left in Polish, not in scope for the
# backend English translation pass.
cfg = self.app.cfg.get('hamlibServers', [])
defaults = [
{"port": 4532, "enabled": True, "label": "Broadcast (wszyscy uzytkownicy)"},
{"port": 4533, "enabled": False, "label": "Dodatkowy port 2"},
{"port": 4534, "enabled": False, "label": "Dodatkowy port 3"},
]
result = []
for i, d in enumerate(defaults):
c = cfg[i] if i < len(cfg) else {}
result.append({**d, **c})
return result
async def start_all(self):
for i, c in enumerate(self._get_config()):
if not c.get('enabled', i == 0):
continue
port = c['port']
# Check actual availability — don't blindly trust the saved
# config (the port could have been taken by rigctld or another
# process since the settings panel last saved).
import socket as _sk
_t = _sk.socket(); _t.settimeout(0.3)
_busy = _t.connect_ex(('127.0.0.1', port)) == 0
_t.close()
if _busy:
print(f"[hamlib] Port {port} in use (another process) — skipping slot {i+1}", flush=True)
continue
srv = VirtualRigServer(port, i, self.app)
await srv.start()
self.servers.append(srv)
async def stop_all(self):
for srv in self.servers:
await srv.stop()
self.servers.clear()
async def restart(self):
await self.stop_all()
await self.start_all()
def status(self):
configs = self._get_config()
result = []
for i, c in enumerate(configs):
srv = next((s for s in self.servers if s.slot_idx == i), None)
result.append({
**c, "slot": i,
"running": srv.running if srv else False,
"clients": srv.connected_clients if srv else 0,
})
return result