Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion src/open_sstv/radio/rigctld.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,10 @@ def _send_recv_locked(self, command: str) -> list[str]:
command=command,
rprt=rprt,
)
return lines[:-1] # drop the 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
Expand Down Expand Up @@ -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``.

Expand Down
51 changes: 39 additions & 12 deletions tests/radio/fake_rigctld.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -151,42 +154,66 @@ 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 = self._response_header(command_text, cmd)

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"

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"]
19 changes: 19 additions & 0 deletions tests/radio/test_rigctld_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ===


Expand Down
Loading