Ctrl K
ComunidadGuías…

hamlib_server.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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