From 93057ec958c9f86cbaf657427205796072b00b60 Mon Sep 17 00:00:00 2001 From: worldwidedx Date: Fri, 28 Aug 2026 16:03:27 -0700 Subject: [PATCH 1/2] Fix rigctld extended response parsing --- src/open_sstv/radio/rigctld.py | 2 +- tests/radio/fake_rigctld.py | 28 ++++++++++++++++------------ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/open_sstv/radio/rigctld.py b/src/open_sstv/radio/rigctld.py index 56971d6..253f63d 100644 --- a/src/open_sstv/radio/rigctld.py +++ b/src/open_sstv/radio/rigctld.py @@ -251,7 +251,7 @@ def _send_recv_locked(self, command: str) -> list[str]: command=command, rprt=rprt, ) - return lines[:-1] # drop the RPRT terminator + return lines[1:-1] # drop the echoed command header and RPRT terminator #: Maximum number of lines accepted in a single rigctld response. #: Guards against unbounded buffer growth if the daemon sends garbage diff --git a/tests/radio/fake_rigctld.py b/tests/radio/fake_rigctld.py index 87c5675..81c8ea4 100644 --- a/tests/radio/fake_rigctld.py +++ b/tests/radio/fake_rigctld.py @@ -151,42 +151,46 @@ def _dispatch(self, command_text: str) -> str: cmd = command_text[1:] if command_text.startswith("+") else command_text self.commands_received.append(cmd) + # Hamlib's extended response mode echoes the command header before + # the response body and final ``RPRT`` status line. + header = f"{cmd}:\n" if command_text.startswith("+") else "" + if self.fail_all_commands: - return "RPRT -1\n" + return header + "RPRT -1\n" if self.fail_next_command: self.fail_next_command = False - return "RPRT -1\n" + return header + "RPRT -1\n" parts = cmd.split() if not parts: - return "RPRT -1\n" + return header + "RPRT -1\n" op = parts[0] try: if op == "f": - return f"Frequency: {self.freq}\nRPRT 0\n" + return header + f"Frequency: {self.freq}\nRPRT 0\n" if op == "F": self.freq = int(parts[1]) - return "RPRT 0\n" + return header + "RPRT 0\n" if op == "m": - return ( + return header + ( f"Mode: {self.mode_name}\nPassband: {self.passband_hz}\nRPRT 0\n" ) if op == "M": self.mode_name = parts[1] self.passband_hz = int(parts[2]) - return "RPRT 0\n" + return header + "RPRT 0\n" if op == "t": - return f"PTT: {1 if self.ptt else 0}\nRPRT 0\n" + return header + f"PTT: {1 if self.ptt else 0}\nRPRT 0\n" if op == "T": self.ptt = parts[1] == "1" - return "RPRT 0\n" + return header + "RPRT 0\n" if op == "l" and len(parts) >= 2 and parts[1] == "STRENGTH": - return f"STRENGTH: {self.strength_db}\nRPRT 0\n" + return header + f"STRENGTH: {self.strength_db}\nRPRT 0\n" except (IndexError, ValueError): - return "RPRT -1\n" + return header + "RPRT -1\n" - return "RPRT -1\n" + return header + "RPRT -1\n" __all__ = ["FakeRigctld"] From 1dfbc11b20a92e71216bf2737aaf9224f20e916a Mon Sep 17 00:00:00 2001 From: worldwidedx Date: Mon, 31 Aug 2026 19:02:28 -0700 Subject: [PATCH 2/2] Accept optional rigctld response headers --- src/open_sstv/radio/rigctld.py | 29 ++++++++++++++++++++++++++++- tests/radio/fake_rigctld.py | 25 ++++++++++++++++++++++++- tests/radio/test_rigctld_client.py | 19 +++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/open_sstv/radio/rigctld.py b/src/open_sstv/radio/rigctld.py index 253f63d..a99a177 100644 --- a/src/open_sstv/radio/rigctld.py +++ b/src/open_sstv/radio/rigctld.py @@ -251,7 +251,10 @@ def _send_recv_locked(self, command: str) -> list[str]: command=command, rprt=rprt, ) - return lines[1:-1] # drop the echoed command header and RPRT terminator + body = lines[:-1] # drop the RPRT terminator + if body and _is_echoed_command_header(body[0], command): + body = body[1:] + return body #: Maximum number of lines accepted in a single rigctld response. #: Guards against unbounded buffer growth if the daemon sends garbage @@ -307,6 +310,30 @@ def _read_until_rprt(self) -> list[str]: return lines +_LONG_COMMAND_NAMES: dict[str, str] = { + "f": "get_freq", + "F": "set_freq", + "m": "get_mode", + "M": "set_mode", + "t": "get_ptt", + "T": "set_ptt", + "l": "get_level", +} + + +def _is_echoed_command_header(line: str, command: str) -> bool: + """Return whether *line* is Hamlib's optional extended-command echo. + + Hamlib versions/backends differ: some extended responses begin directly + with response fields, while others echo the long command name first. An + echo can include an argument (``get_level: STRENGTH``), so testing only + for a trailing colon would fail for level reads. + """ + op = command.split(maxsplit=1)[0] + long_name = _LONG_COMMAND_NAMES.get(op) + return long_name is not None and line.startswith(f"{long_name}:") + + def _parse_int(line: str, *, field: str, command: str) -> int: """Parse a numeric extended-response value into an ``int``. diff --git a/tests/radio/fake_rigctld.py b/tests/radio/fake_rigctld.py index 81c8ea4..42d5edb 100644 --- a/tests/radio/fake_rigctld.py +++ b/tests/radio/fake_rigctld.py @@ -41,6 +41,9 @@ def __init__(self) -> None: self.passband_hz: int = 2400 self.ptt: bool = False self.strength_db: int = -73 + #: Whether extended responses include Hamlib's long command echo. + #: Both framing variants exist in deployed Hamlib versions/backends. + self.echo_header: bool = False # Test hooks. self.commands_received: list[str] = [] #: When True, the next command receives ``RPRT -1`` instead of being @@ -153,7 +156,7 @@ def _dispatch(self, command_text: str) -> str: # Hamlib's extended response mode echoes the command header before # the response body and final ``RPRT`` status line. - header = f"{cmd}:\n" if command_text.startswith("+") else "" + header = self._response_header(command_text, cmd) if self.fail_all_commands: return header + "RPRT -1\n" @@ -192,5 +195,25 @@ def _dispatch(self, command_text: str) -> str: return header + "RPRT -1\n" + def _response_header(self, command_text: str, cmd: str) -> str: + """Return the optional long-name header used by some Hamlib daemons.""" + if not command_text.startswith("+") or not self.echo_header: + return "" + parts = cmd.split(maxsplit=1) + if not parts: + return "" + long_name = { + "f": "get_freq", + "F": "set_freq", + "m": "get_mode", + "M": "set_mode", + "t": "get_ptt", + "T": "set_ptt", + "l": "get_level", + }.get(parts[0]) + if long_name is None: + return "" + argument = f" {parts[1]}" if len(parts) == 2 else "" + return f"{long_name}:{argument}\n" __all__ = ["FakeRigctld"] diff --git a/tests/radio/test_rigctld_client.py b/tests/radio/test_rigctld_client.py index aea0e6c..7a1d797 100644 --- a/tests/radio/test_rigctld_client.py +++ b/tests/radio/test_rigctld_client.py @@ -76,6 +76,16 @@ def test_get_freq(client: RigctldClient, fake: FakeRigctld) -> None: assert client.get_freq() == 14_070_000 +@pytest.mark.parametrize("echo_header", [False, True]) +def test_get_freq_accepts_both_extended_response_framings( + client: RigctldClient, fake: FakeRigctld, echo_header: bool +) -> None: + """Hamlib may include or omit the optional long-command response echo.""" + fake.echo_header = echo_header + fake.freq = 14_230_000 + assert client.get_freq() == 14_230_000 + + def test_set_freq(client: RigctldClient, fake: FakeRigctld) -> None: client.set_freq(14_250_000) assert fake.freq == 14_250_000 @@ -135,6 +145,15 @@ def test_get_strength(client: RigctldClient, fake: FakeRigctld) -> None: assert client.get_strength() == -42 +def test_get_strength_accepts_echo_header_with_argument( + client: RigctldClient, fake: FakeRigctld +) -> None: + """``get_level: STRENGTH`` is an echoed header, not a data field.""" + fake.echo_header = True + fake.strength_db = -24 + assert client.get_strength() == -24 + + # === ping ===