Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/rigplane/backends/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def create_radio(config: BackendConfig) -> Radio:
return YaesuCatRadio(
device=config.device,
baudrate=config.baudrate,
profile=config.model or "ftx1",
rx_device=config.rx_device,
tx_device=config.tx_device,
audio_sample_rate=config.audio_sample_rate,
Expand All @@ -71,6 +72,7 @@ def create_radio(config: BackendConfig) -> Radio:
return YaesuCatRadio(
device=config.device,
baudrate=config.baudrate or 38400,
profile=config.profile or config.model or "ftx1",
rx_device=config.rx_device,
tx_device=config.tx_device,
audio_sample_rate=config.audio_sample_rate or 48000,
Expand Down
9 changes: 2 additions & 7 deletions src/rigplane/backends/yaesu_cat/radio.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from __future__ import annotations

import logging
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Literal, Sequence

from ...audio import AudioPacket
Expand Down Expand Up @@ -37,9 +36,6 @@

logger = logging.getLogger(__name__)

# Path to rigs/ directory: src/rigplane/backends/yaesu_cat/radio.py → 4 levels up
_RIGS_DIR = Path(__file__).parents[4] / "rigs"

# CTCSS tone chart (MOR-458). The FTX-1 CAT ``CN`` "CTCSS TONE FREQUENCY"
# command reports the tone as a 0-49 INDEX into the standard 50-tone EIA CTCSS
# set, NOT as an absolute frequency (unlike the Icom 0x1B BCD-Hz encoding). The
Expand Down Expand Up @@ -118,11 +114,10 @@ def _ctcss_index_to_centihz(index: int) -> int:

def _load_config(profile: Any) -> "RigConfig":
"""Load RigConfig from a profile name or return an existing RigConfig."""
from ...rig_loader import RigConfig, load_rig
from ...rig_loader import RigConfig, load_rig_resource

if isinstance(profile, str):
path = _RIGS_DIR / f"{profile}.toml"
return load_rig(path)
return load_rig_resource(profile)
if isinstance(profile, RigConfig):
return profile
raise TypeError(f"profile must be str or RigConfig, got {type(profile).__name__}")
Expand Down
6 changes: 4 additions & 2 deletions src/rigplane/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1558,15 +1558,15 @@ def _resolve_model(
Returns:
(radio_addr, model_name, default_baud) — each may be None.
"""
from rigplane.rig_loader import discover_rigs
from rigplane.rig_loader import RigLoadError, discover_available_rigs

model_name: str | None = getattr(args, "model", None)
radio_addr: int | None = getattr(args, "radio_addr", None)

if model_name is None:
return radio_addr, None, None

rigs = discover_rigs(_rigs_dir())
rigs = discover_available_rigs()

# Case-insensitive match by model name or by rig id
matched = None
Expand All @@ -1580,6 +1580,8 @@ def _resolve_model(

if matched is None:
available = ", ".join(sorted(rigs.keys()))
if not available:
raise RigLoadError("No rig profiles loaded")
raise ValueError(f"Unknown model {model_name!r}. Available: {available}")

# --radio-addr overrides profile civ_addr
Expand Down
30 changes: 10 additions & 20 deletions src/rigplane/profiles/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,8 @@ def resolve_filter_rule(
_by_id: dict[str, RadioProfile] = {}
_by_civ_addr: dict[int, RadioProfile] = {}

# Search paths for rig TOML files (first existing directory wins).
# Legacy search paths kept for private callers that inspected this constant.
# New code must use rig_loader.rig_resource_roots().
_RIG_DIRS: list[Path] = [
Path(__file__).resolve().parent.parent.parent.parent
/ "rigs", # dev: repo root/rigs/
Expand All @@ -295,35 +296,24 @@ def _ensure_loaded() -> dict[str, RadioProfile]:
return _profiles

# Import here to avoid circular imports
from .rig_loader import discover_rigs
from .rig_loader import discover_available_rigs, rig_resource_roots

_profiles = {}
_by_normalized = {}
_by_id = {}
_by_civ_addr = {}

for rig_dir in _RIG_DIRS:
if rig_dir.is_dir():
rigs = discover_rigs(rig_dir)
for model, rig_config in rigs.items():
profile = rig_config.to_profile()
_profiles[model] = profile
_by_normalized[_normalize(model)] = profile
_by_id[_normalize(profile.id)] = profile
_by_civ_addr.setdefault(profile.civ_addr, profile)
if rigs:
logger.debug(
"Loaded %d rig profiles from %s: %s",
len(rigs),
rig_dir,
", ".join(sorted(rigs.keys())),
)
break # use first directory that has rigs
for model, rig_config in discover_available_rigs().items():
profile = rig_config.to_profile()
_profiles[model] = profile
_by_normalized[_normalize(model)] = profile
_by_id[_normalize(profile.id)] = profile
_by_civ_addr.setdefault(profile.civ_addr, profile)

if not _profiles:
logger.warning(
"No rig TOML profiles found in search paths: %s",
[str(p) for p in _RIG_DIRS],
[str(root) for root in rig_resource_roots()],
)

return _profiles
Expand Down
104 changes: 103 additions & 1 deletion src/rigplane/profiles/rig_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
from __future__ import annotations

import logging
import os
import tomllib
import warnings
from dataclasses import dataclass
from importlib import resources
from pathlib import Path
from typing import Any

Expand All @@ -23,7 +25,15 @@
from rigplane.core.state_pipeline_contracts import FieldPath
from rigplane.commands.command_map import CommandMap

__all__ = ["RigConfig", "RigLoadError", "load_rig", "discover_rigs"]
__all__ = [
"RigConfig",
"RigLoadError",
"load_rig",
"discover_rigs",
"discover_available_rigs",
"load_rig_resource",
"rig_resource_roots",
]
from rigplane.commands.command_spec import CatCommandSpec, CivCommandSpec, CommandSpec
from rigplane.profiles import (
BandInfo,
Expand Down Expand Up @@ -55,6 +65,7 @@
VALID_BROWSER_RX_TRANSPORTS = {"auto", "pcm", "opus"}
VALID_RX_AUDIO_CHANNELS = {"mix", "left", "right"}
DEFAULT_KEYBOARD_PROFILE_NAME = "_keyboard-default.toml"
RIGS_DIR_ENV = "RIGPLANE_RIGS_DIR"

_REQUIRED_SECTIONS = ("radio", "capabilities", "modes", "filters", "vfo")
_REQUIRED_RADIO_FIELDS = ("id", "model", "receiver_count", "has_lan", "has_wifi")
Expand Down Expand Up @@ -1450,3 +1461,94 @@ def discover_rigs(directory: Path) -> dict[str, RigConfig]:
rigs[rig.model] = rig

return rigs


def rig_resource_roots() -> tuple[Any, ...]:
"""Return rig-profile roots in supported lookup order.

Package resources are the canonical installed layout. The repo-root
fallback keeps editable checkouts working without copying ``rigs/`` into
``src/rigplane``. ``RIGPLANE_RIGS_DIR`` is a test/dev override, not the
normal packaged-app path.
"""
roots: list[Any] = []
override = os.environ.get(RIGS_DIR_ENV, "").strip()
if override:
roots.append(Path(override))
try:
roots.append(resources.files("rigplane").joinpath("rigs"))
except (AttributeError, ModuleNotFoundError):
pass
roots.append(Path(__file__).resolve().parents[3] / "rigs")

deduped: list[Any] = []
seen: set[str] = set()
for root in roots:
marker = str(root)
if marker in seen:
continue
seen.add(marker)
deduped.append(root)
return tuple(deduped)


def _iter_resource_tomls(root: Any) -> list[Any]:
if not root.is_dir():
return []
return sorted(
(
child
for child in root.iterdir()
if child.name.endswith(".toml")
and not child.name.startswith("_")
and not child.name.endswith(".draft.toml")
),
key=lambda child: child.name,
)


def _load_rig_resource_file(resource: Any) -> RigConfig:
if isinstance(resource, Path):
return load_rig(resource)
with resources.as_file(resource) as path:
return load_rig(path)


def discover_available_rigs() -> dict[str, RigConfig]:
"""Discover rig TOML profiles from the supported resource roots."""
for root in rig_resource_roots():
rigs: dict[str, RigConfig] = {}
for resource in _iter_resource_tomls(root):
rig = _load_rig_resource_file(resource)
rigs[rig.model] = rig
if rigs:
logger.debug(
"Loaded %d rig profiles from %s: %s",
len(rigs),
root,
", ".join(sorted(rigs.keys())),
)
return rigs
logger.warning(
"No rig TOML profiles found in resource roots: %s",
[str(root) for root in rig_resource_roots()],
)
return {}


def _normalize_resource_key(value: str) -> str:
return "".join(ch for ch in value.upper() if ch.isalnum())


def load_rig_resource(name_or_id: str) -> RigConfig:
"""Load a bundled rig profile by model name or profile id."""
key = _normalize_resource_key(name_or_id)
rigs = discover_available_rigs()
for rig in rigs.values():
if (
_normalize_resource_key(rig.model) == key
or _normalize_resource_key(rig.id) == key
):
return rig
available = ", ".join(sorted(rigs.keys()))
raise RigLoadError(f"Unknown rig profile {name_or_id!r}. Available: {available}")
20 changes: 20 additions & 0 deletions tests/test_ftx1_radio.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

import pytest

import rigplane.rig_loader as rig_loader
from rigplane.backends.yaesu_cat.radio import YaesuCatRadio
from rigplane.backends.yaesu_cat import radio as yaesu_radio
from rigplane.backends.yaesu_cat.parser import CatParseError
from rigplane.backends.yaesu_cat.transport import CatTimeoutError
from rigplane.exceptions import CommandError
Expand Down Expand Up @@ -53,6 +55,24 @@ def test_instantiation_with_string_profile():
assert not r.connected


def test_string_profile_uses_shared_rig_resource_resolver(monkeypatch, config):
"""String profiles must not depend on backend-local filesystem paths."""
observed: dict[str, str] = {}

def fake_load_rig_resource(name_or_id: str):
observed["name_or_id"] = name_or_id
return config

def fail_load_rig(_path: Path):
raise AssertionError("Yaesu string profiles must use load_rig_resource()")

monkeypatch.setattr(rig_loader, "load_rig_resource", fake_load_rig_resource)
monkeypatch.setattr(rig_loader, "load_rig", fail_load_rig)

assert yaesu_radio._load_config("FTX-1") is config
assert observed == {"name_or_id": "FTX-1"}


def test_instantiation_with_rig_config(config):
"""Can create a radio using an already-loaded RigConfig."""
r = YaesuCatRadio("/dev/null", profile=config)
Expand Down
30 changes: 29 additions & 1 deletion tests/test_rig_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@

from rigplane.command_map import CommandMap
from rigplane.profiles import BandInfo, FreqRangeInfo, RadioProfile, get_radio_profile
from rigplane.rig_loader import RigConfig, RigLoadError, discover_rigs, load_rig
from rigplane.rig_loader import (
RigConfig,
RigLoadError,
discover_available_rigs,
discover_rigs,
load_rig,
load_rig_resource,
)

RIGS_DIR = Path(__file__).resolve().parent.parent / "rigs"
TEMPLATE_PATH = RIGS_DIR / "ic7610.toml"
Expand Down Expand Up @@ -617,6 +624,27 @@ def test_empty_directory(self, tmp_path):
assert rigs == {}


class TestRigResourceResolver:
"""Supported rig resource lookup used by packaged consumers."""

def test_discovers_profiles_from_env_override(self, monkeypatch, tmp_path):
(tmp_path / "ftx1.toml").write_bytes((RIGS_DIR / "ftx1.toml").read_bytes())
monkeypatch.setenv("RIGPLANE_RIGS_DIR", str(tmp_path))

rigs = discover_available_rigs()

assert sorted(rigs) == ["FTX-1"]

def test_loads_profile_by_model_or_id_from_supported_roots(
self, monkeypatch, tmp_path
):
(tmp_path / "ftx1.toml").write_bytes((RIGS_DIR / "ftx1.toml").read_bytes())
monkeypatch.setenv("RIGPLANE_RIGS_DIR", str(tmp_path))

assert load_rig_resource("FTX-1").model == "FTX-1"
assert load_rig_resource("ftx1").model == "FTX-1"


class TestCodecPreference:
"""Per-profile [audio] codec_preference override (#797)."""

Expand Down
8 changes: 8 additions & 0 deletions tests/test_yaesu_cli_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ def test_serial_ftx1_backward_compat(self):
radio = create_radio(cfg)
assert isinstance(radio, YaesuCatRadio)

def test_serial_ft_model_is_passed_to_yaesu_radio(self):
cfg = SerialBackendConfig(device="/dev/ttyUSB0", model="FTX-1")
with patch("rigplane.backends.factory.YaesuCatRadio") as constructor:
create_radio(cfg)

constructor.assert_called_once()
assert constructor.call_args.kwargs["profile"] == "FTX-1"


# ---------------------------------------------------------------------------
# CLI _build_backend_config
Expand Down
Loading