Remember runtime lock-outs, and let a lock-out be a span
Pressing `l` during a scan locked a frequency out for that run only, so the same birdie had to be locked out again on every later one. It now writes back to the settings file the run started from -- only that one key, since a scan's config also holds whatever was passed on the command line for this run and saving all of it would quietly make those permanent. The file is read, its lock-outs replaced, the rest left as it was. `save_lockouts` turns it off for anyone who would rather their config were never touched. Lock-outs were also single frequencies only. They are now a list of frequencies and spans -- "162.55M, 450M-455M, 88M to 108M" -- which is what a pager band or a noisy stretch of spectrum actually is. A point is still widened by the lock-out width; a span is taken exactly as written, because whoever typed it already said how wide it is. The scanner matched lock-outs by rounding a frequency into a bucket of the lock-out width, which cannot express a span and was never exact at the edges. It now holds intervals and tests them directly. Settings files that predate this hold a bare number per lock-out, and still mean the same thing: Lockout.coerce takes numbers, strings, pairs and dicts, so old profiles load untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
b69d0b7a26
commit
44c98b11e3
9 changed files with 319 additions and 46 deletions
24
README.md
24
README.md
|
|
@ -823,9 +823,31 @@ since that is the command to run when something is wrong, and
|
||||||
| `q` | stop |
|
| `q` | stop |
|
||||||
| `p` | pause / resume |
|
| `p` | pause / resume |
|
||||||
| `s` | skip this signal, resume sweeping |
|
| `s` | skip this signal, resume sweeping |
|
||||||
| `l` | lock out this frequency for the rest of the run |
|
| `l` | lock out this frequency — for this run and every later one |
|
||||||
| `+` / `-` | adjust the squelch threshold |
|
| `+` / `-` | adjust the squelch threshold |
|
||||||
|
|
||||||
|
### Lock-outs
|
||||||
|
|
||||||
|
A pager transmitter down the road, or a birdie the receiver makes itself, is
|
||||||
|
worth shutting out permanently. Pressing `l` writes the frequency back to the
|
||||||
|
settings file the run started from, so it is still locked out tomorrow. Only
|
||||||
|
that one setting is written back — options passed on the command line for a
|
||||||
|
single run stay one-off — and `--no-save-lockouts` keeps a lock-out to the
|
||||||
|
current run.
|
||||||
|
|
||||||
|
Lock-outs can also be given directly, several at a time, as single frequencies
|
||||||
|
or as spans:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bandsaunter scan -r 144M-148M --lockout "162.55M, 450M-455M"
|
||||||
|
bandsaunter config lockout="88M-108M, 146.52M"
|
||||||
|
```
|
||||||
|
|
||||||
|
A single frequency is widened by `--lockout-width` (12.5 kHz by default); a
|
||||||
|
span is taken exactly as written, since a noisy stretch of spectrum has a
|
||||||
|
definite width rather than a point with a guess around it. Ranges accept the
|
||||||
|
same forms as everywhere else — `450M-455M`, `450-455M`, `88M to 108M`.
|
||||||
|
|
||||||
## Built-in help
|
## Built-in help
|
||||||
|
|
||||||
Press `h` in the menus for topics covering setup, how the sweep works, why
|
Press `h` in the menus for topics covering setup, how the sweep works, why
|
||||||
|
|
|
||||||
|
|
@ -397,8 +397,8 @@ def _handle_key(key: str, scanner: Scanner, display: ScanDisplay) -> None:
|
||||||
elif k == "l":
|
elif k == "l":
|
||||||
freq = display._rec.frequency or scanner.stats.current_freq
|
freq = display._rec.frequency or scanner.stats.current_freq
|
||||||
if freq:
|
if freq:
|
||||||
|
# The scanner says so itself, and says whether it was remembered.
|
||||||
scanner.lockout(freq)
|
scanner.lockout(freq)
|
||||||
display.on_status(f"locked out {fmt_hz(freq)}")
|
|
||||||
scanner.skip()
|
scanner.skip()
|
||||||
elif k in ("+", "="):
|
elif k in ("+", "="):
|
||||||
scanner.cfg.threshold_db += 1.0
|
scanner.cfg.threshold_db += 1.0
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,12 @@ from pathlib import Path
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from .ranges import ScanRange, parse_range_list
|
from .ranges import Lockout, ScanRange, parse_range_list
|
||||||
|
|
||||||
__all__ = ["ScanConfig", "DEFAULT_CONFIG_DIR", "DEFAULT_CONFIG_PATH",
|
__all__ = ["ScanConfig", "DEFAULT_CONFIG_DIR", "DEFAULT_CONFIG_PATH",
|
||||||
"load_config", "save_config", "list_profiles", "profile_path",
|
"load_config", "save_config", "list_profiles", "profile_path",
|
||||||
"load_default", "save_default", "delete_profile",
|
"load_default", "save_default", "delete_profile",
|
||||||
"is_first_run", "DEFAULT_OUTPUT_DIR"]
|
"is_first_run", "DEFAULT_OUTPUT_DIR", "remember_lockouts"]
|
||||||
|
|
||||||
DEFAULT_CONFIG_DIR = Path(
|
DEFAULT_CONFIG_DIR = Path(
|
||||||
os.environ.get("BANDSAUNTER_CONFIG_DIR",
|
os.environ.get("BANDSAUNTER_CONFIG_DIR",
|
||||||
|
|
@ -120,8 +120,9 @@ class ScanConfig:
|
||||||
transcribe_min_seconds: float = 1.0
|
transcribe_min_seconds: float = 1.0
|
||||||
|
|
||||||
# -- behaviour ------------------------------------------------------
|
# -- behaviour ------------------------------------------------------
|
||||||
lockout: list[float] = field(default_factory=list)
|
lockout: list[Lockout] = field(default_factory=list)
|
||||||
lockout_width: float = 12_500.0
|
lockout_width: float = 12_500.0
|
||||||
|
save_lockouts: bool = True # write runtime lock-outs back to disk
|
||||||
revisit_seconds: float = 8.0 # ignore a frequency again this soon
|
revisit_seconds: float = 8.0 # ignore a frequency again this soon
|
||||||
max_cycles: int = 0 # 0 = run forever
|
max_cycles: int = 0 # 0 = run forever
|
||||||
max_runtime_seconds: float = 0.0 # 0 = no limit
|
max_runtime_seconds: float = 0.0 # 0 = no limit
|
||||||
|
|
@ -130,6 +131,12 @@ class ScanConfig:
|
||||||
log_file: str = "scan_log.jsonl"
|
log_file: str = "scan_log.jsonl"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
def __post_init__(self):
|
||||||
|
# Lock-outs arrive as bare numbers from older settings files, as
|
||||||
|
# dicts from newer ones, and as either from callers. One type from
|
||||||
|
# here on.
|
||||||
|
self.lockout = [Lockout.coerce(v) for v in self.lockout]
|
||||||
|
|
||||||
def validate(self) -> list[str]:
|
def validate(self) -> list[str]:
|
||||||
"""Return a list of human-readable problems (empty when config is sane)."""
|
"""Return a list of human-readable problems (empty when config is sane)."""
|
||||||
errs = []
|
errs = []
|
||||||
|
|
@ -270,6 +277,37 @@ def save_default(cfg: "ScanConfig", directory: Path | None = None) -> Path:
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def remember_lockouts(cfg: "ScanConfig",
|
||||||
|
directory: Path | None = None) -> Path | None:
|
||||||
|
"""Write the lock-out list back to the settings file it came from.
|
||||||
|
|
||||||
|
Only that one key: a scan's config also holds whatever was passed on the
|
||||||
|
command line for this run, and saving all of it would quietly make those
|
||||||
|
one-off options permanent. So the file on disk is read, its lock-outs
|
||||||
|
replaced, and the rest left exactly as it was.
|
||||||
|
|
||||||
|
Returns where it was written, or None if it could not be -- locking out a
|
||||||
|
birdie must never be the thing that ends a scan.
|
||||||
|
"""
|
||||||
|
path = getattr(cfg, "_source_path", None)
|
||||||
|
path = Path(path) if path else (Path(directory or DEFAULT_CONFIG_DIR)
|
||||||
|
/ "config.yaml")
|
||||||
|
try:
|
||||||
|
data = {}
|
||||||
|
if path.exists():
|
||||||
|
with open(path) as fh:
|
||||||
|
data = yaml.safe_load(fh) or {}
|
||||||
|
data["lockout"] = [lk.to_dict() for lk in cfg.lockout]
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
with open(tmp, "w") as fh:
|
||||||
|
yaml.safe_dump(data, fh, sort_keys=False, default_flow_style=False)
|
||||||
|
tmp.replace(path)
|
||||||
|
return path
|
||||||
|
except (OSError, yaml.YAMLError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def delete_profile(name: str, directory: Path | None = None) -> bool:
|
def delete_profile(name: str, directory: Path | None = None) -> bool:
|
||||||
path = profile_path(name, directory)
|
path = profile_path(name, directory)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@ from dataclasses import asdict, dataclass
|
||||||
|
|
||||||
from .bandplan import BandPreset, by_key, fmt_hz, presets_covering
|
from .bandplan import BandPreset, by_key, fmt_hz, presets_covering
|
||||||
|
|
||||||
__all__ = ["ScanRange", "TuneStep", "parse_frequency", "parse_range",
|
__all__ = ["ScanRange", "TuneStep", "Lockout", "parse_frequency", "parse_range",
|
||||||
"parse_range_list", "build_plan", "fmt_hz", "RangeError"]
|
"parse_range_list", "parse_lockout_list", "build_plan", "fmt_hz",
|
||||||
|
"RangeError"]
|
||||||
|
|
||||||
|
|
||||||
class RangeError(ValueError):
|
class RangeError(ValueError):
|
||||||
|
|
@ -313,3 +314,105 @@ def plan_summary(ranges: list[ScanRange], steps: list[TuneStep],
|
||||||
cycle = len(steps) * dwell_seconds
|
cycle = len(steps) * dwell_seconds
|
||||||
return (f"{n_en} range(s), {fmt_hz(total)} total span, {len(steps)} tuner "
|
return (f"{n_en} range(s), {fmt_hz(total)} total span, {len(steps)} tuner "
|
||||||
f"steps, ~{cycle:.1f} s per full sweep")
|
f"steps, ~{cycle:.1f} s per full sweep")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lock-outs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Lockout:
|
||||||
|
"""A frequency, or a span of them, the scan must never stop on.
|
||||||
|
|
||||||
|
A single frequency is stored with ``stop`` equal to ``start`` and is
|
||||||
|
widened by the lock-out width setting when it is applied, so changing that
|
||||||
|
width still moves it. A span is taken exactly as given -- a pager band or
|
||||||
|
a noisy stretch of spectrum is a definite width, not a point with a guess
|
||||||
|
around it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
start: float
|
||||||
|
stop: float = 0.0
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self.start = float(self.start)
|
||||||
|
self.stop = float(self.stop or self.start)
|
||||||
|
if self.stop < self.start:
|
||||||
|
self.start, self.stop = self.stop, self.start
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_span(self) -> bool:
|
||||||
|
return self.stop > self.start
|
||||||
|
|
||||||
|
def interval(self, width: float) -> tuple[float, float]:
|
||||||
|
"""The span this covers, given the width a point lock-out gets."""
|
||||||
|
if self.is_span:
|
||||||
|
return self.start, self.stop
|
||||||
|
half = max(1.0, float(width)) / 2.0
|
||||||
|
return self.start - half, self.start + half
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
if self.is_span:
|
||||||
|
return f"{fmt_hz(self.start)}-{fmt_hz(self.stop)}"
|
||||||
|
return fmt_hz(self.start)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {"start": self.start, "stop": self.stop}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def coerce(cls, value) -> "Lockout":
|
||||||
|
"""Accept anything a saved file or a caller might reasonably hold.
|
||||||
|
|
||||||
|
Older settings files stored a bare number per lock-out, and callers
|
||||||
|
pass plain frequencies, so both still mean what they always did.
|
||||||
|
"""
|
||||||
|
if isinstance(value, cls):
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return cls(value.get("start", 0.0), value.get("stop", 0.0))
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
if len(value) == 1:
|
||||||
|
return cls(float(value[0]))
|
||||||
|
if len(value) == 2:
|
||||||
|
return cls(float(value[0]), float(value[1]))
|
||||||
|
raise RangeError(f"cannot read {value!r} as a lock-out")
|
||||||
|
if isinstance(value, str):
|
||||||
|
return parse_lockout(value)
|
||||||
|
return cls(float(value))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_lockout(text: str) -> Lockout:
|
||||||
|
"""One lock-out: ``162.55M`` or ``162.4M-162.6M``."""
|
||||||
|
if text is None:
|
||||||
|
raise RangeError("empty lock-out")
|
||||||
|
s = str(text).strip()
|
||||||
|
if not s:
|
||||||
|
raise RangeError("empty lock-out")
|
||||||
|
parts = [p for p in _RANGE_SPLIT.split(s) if p.strip()]
|
||||||
|
if len(parts) == 1:
|
||||||
|
return Lockout(parse_frequency(parts[0]))
|
||||||
|
if len(parts) == 2:
|
||||||
|
# "162.4-162.6M": the unit on the right end applies to the left too.
|
||||||
|
right = _NUM.match(parts[1])
|
||||||
|
unit = right.group(2) if right else ""
|
||||||
|
return Lockout(parse_frequency(parts[0], default_unit=unit),
|
||||||
|
parse_frequency(parts[1]))
|
||||||
|
raise RangeError(f"cannot read {text!r} as a lock-out")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_lockout_list(text) -> list[Lockout]:
|
||||||
|
"""A comma or semicolon separated list of frequencies and spans."""
|
||||||
|
if text is None:
|
||||||
|
return []
|
||||||
|
if isinstance(text, (list, tuple)):
|
||||||
|
items = text
|
||||||
|
else:
|
||||||
|
items = re.split(r"[,;\n]", str(text))
|
||||||
|
out = []
|
||||||
|
for item in items:
|
||||||
|
if isinstance(item, str):
|
||||||
|
item = item.strip()
|
||||||
|
if not item or item.lower() in ("(none)", "none", "-"):
|
||||||
|
continue
|
||||||
|
out.append(Lockout.coerce(item))
|
||||||
|
return out
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,12 @@ import numpy as np
|
||||||
from . import dsp
|
from . import dsp
|
||||||
from .bandplan import fmt_hz, presets_covering
|
from .bandplan import fmt_hz, presets_covering
|
||||||
from .classify import classify, ssb_alignment
|
from .classify import classify, ssb_alignment
|
||||||
from .config import ScanConfig
|
from .config import ScanConfig, remember_lockouts
|
||||||
from .demod import make_demodulator
|
from .demod import make_demodulator
|
||||||
from .device import RtlSdrDevice, RtlSdrError
|
from .device import RtlSdrDevice, RtlSdrError
|
||||||
from .morse import decode_morse
|
from .morse import decode_morse
|
||||||
from .quality import Assessment, assess
|
from .quality import Assessment, assess
|
||||||
from .ranges import TuneStep, build_plan
|
from .ranges import Lockout, TuneStep, build_plan
|
||||||
from .recorder import FrequencyLog, HitRecord, Recording, ScanLog, read_wav
|
from .recorder import FrequencyLog, HitRecord, Recording, ScanLog, read_wav
|
||||||
from .transcribe import TranscriptionWorker, available_engine
|
from .transcribe import TranscriptionWorker, available_engine
|
||||||
|
|
||||||
|
|
@ -99,7 +99,7 @@ class Scanner:
|
||||||
self.plan: list[TuneStep] = []
|
self.plan: list[TuneStep] = []
|
||||||
self._floors: dict[int, dsp.NoiseFloorTracker] = {}
|
self._floors: dict[int, dsp.NoiseFloorTracker] = {}
|
||||||
self._recent: dict[int, float] = {} # frequency bucket -> last visit
|
self._recent: dict[int, float] = {} # frequency bucket -> last visit
|
||||||
self._lockouts: set[int] = set()
|
self._lockouts: list[tuple[float, float]] = [] # spans, not buckets
|
||||||
|
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
self._pause = threading.Event()
|
self._pause = threading.Event()
|
||||||
|
|
@ -131,15 +131,22 @@ class Scanner:
|
||||||
self._skip.set()
|
self._skip.set()
|
||||||
|
|
||||||
def lockout(self, freq_hz: float) -> None:
|
def lockout(self, freq_hz: float) -> None:
|
||||||
|
"""Never stop here again -- for this run, and for later ones."""
|
||||||
|
entry = Lockout(float(freq_hz))
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._lockouts.add(self._key(freq_hz))
|
self._lockouts.append(entry.interval(self.cfg.lockout_width))
|
||||||
self.cfg.lockout.append(float(freq_hz))
|
self.cfg.lockout.append(entry)
|
||||||
|
if self.cfg.save_lockouts:
|
||||||
|
path = remember_lockouts(self.cfg)
|
||||||
|
self._status(f"locked out {entry.describe()}"
|
||||||
|
+ (f", remembered in {path.name}" if path else
|
||||||
|
" (could not be saved)"))
|
||||||
|
|
||||||
def _key(self, freq_hz: float) -> int:
|
def _key(self, freq_hz: float) -> int:
|
||||||
return int(round(freq_hz / self._bucket))
|
return int(round(freq_hz / self._bucket))
|
||||||
|
|
||||||
def _is_locked(self, freq_hz: float) -> bool:
|
def _is_locked(self, freq_hz: float) -> bool:
|
||||||
return self._key(freq_hz) in self._lockouts
|
return any(lo <= freq_hz <= hi for lo, hi in self._lockouts)
|
||||||
|
|
||||||
# -- setup -------------------------------------------------------------
|
# -- setup -------------------------------------------------------------
|
||||||
def prepare(self) -> None:
|
def prepare(self) -> None:
|
||||||
|
|
@ -192,8 +199,10 @@ class Scanner:
|
||||||
else dsp.detector_bias_db(
|
else dsp.detector_bias_db(
|
||||||
n_seg, self.nfft,
|
n_seg, self.nfft,
|
||||||
"max" if self.cfg.detector == "peak" else "mean"))
|
"max" if self.cfg.detector == "peak" else "mean"))
|
||||||
for f in self.cfg.lockout:
|
# A single frequency is widened by the lock-out width; a span was
|
||||||
self._lockouts.add(self._key(f))
|
# given a width by whoever wrote it and is taken as it stands.
|
||||||
|
self._lockouts = [Lockout.coerce(f).interval(self.cfg.lockout_width)
|
||||||
|
for f in self.cfg.lockout]
|
||||||
|
|
||||||
root = Path(self.cfg.output_dir).expanduser()
|
root = Path(self.cfg.output_dir).expanduser()
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
|
||||||
|
|
@ -376,13 +376,16 @@ SETTINGS: tuple[Setting, ...] = (
|
||||||
S("max_runtime_seconds", "Stop after time", "Run control", "float",
|
S("max_runtime_seconds", "Stop after time", "Run control", "float",
|
||||||
"stop after this long (0 = run until stopped)",
|
"stop after this long (0 = run until stopped)",
|
||||||
"", unit="s", minimum=0.0, flags=("--duration",), metavar="SEC"),
|
"", unit="s", minimum=0.0, flags=("--duration",), metavar="SEC"),
|
||||||
S("lockout", "Locked-out frequencies", "Run control", "freq_list",
|
S("lockout", "Locked-out frequencies", "Run control", "lockout_list",
|
||||||
"never stop on these frequencies",
|
"never stop on these frequencies",
|
||||||
"Useful for a local pager transmitter or a birdie the receiver makes "
|
"Useful for a local pager transmitter or a birdie the receiver makes "
|
||||||
"itself. Frequencies may be written 162.55M, 162550000 or 162.55 MHz. "
|
"itself. Separate several with commas, and give a span as a pair: "
|
||||||
"The lock-out key during a scan adds the current frequency here for "
|
"`162.55M, 450M-455M, 88 MHz to 108 MHz`. A single frequency is "
|
||||||
"the rest of the run.",
|
"widened by the lock-out width below; a span is taken exactly as "
|
||||||
flags=("--lockout",), metavar="FREQ"),
|
"written. The lock-out key during a scan adds the current frequency "
|
||||||
|
"here, and it is remembered for later runs.",
|
||||||
|
flags=("--lockout",), metavar="FREQ|RANGE",
|
||||||
|
example="162.55M, 450M-455M"),
|
||||||
S("lockout_width", "Lock-out width", "Run control", "float",
|
S("lockout_width", "Lock-out width", "Run control", "float",
|
||||||
"how wide a locked-out frequency is",
|
"how wide a locked-out frequency is",
|
||||||
"A signal within half this distance of a locked-out frequency is "
|
"A signal within half this distance of a locked-out frequency is "
|
||||||
|
|
@ -391,6 +394,14 @@ SETTINGS: tuple[Setting, ...] = (
|
||||||
S("quiet", "Quiet output", "Run control", "bool",
|
S("quiet", "Quiet output", "Run control", "bool",
|
||||||
"print errors only",
|
"print errors only",
|
||||||
"", flags=("--quiet",), off_flags=("--no-quiet",)),
|
"", flags=("--quiet",), off_flags=("--no-quiet",)),
|
||||||
|
S("save_lockouts", "Remember lock-outs", "Run control", "bool",
|
||||||
|
"keep frequencies locked out during a scan",
|
||||||
|
"The lock-out key writes the frequency back to the settings file it "
|
||||||
|
"came from, so a birdie stays locked out on every later run instead of "
|
||||||
|
"having to be locked out again. Only that one setting is written back: "
|
||||||
|
"options passed on the command line for a single run stay one-off. "
|
||||||
|
"Turn this off to keep lock-outs for the current run only.",
|
||||||
|
flags=("--save-lockouts",), off_flags=("--no-save-lockouts",)),
|
||||||
S("plain", "Plain display", "Run control", "bool",
|
S("plain", "Plain display", "Run control", "bool",
|
||||||
"print one line per hit instead of the live display",
|
"print one line per hit instead of the live display",
|
||||||
"The live display redraws a spectrum and a table in place, which wants "
|
"The live display redraws a spectrum and a table in place, which wants "
|
||||||
|
|
@ -539,20 +550,14 @@ def parse_value(setting: Setting, text):
|
||||||
f"unknown: {', '.join(bad)} (choose from "
|
f"unknown: {', '.join(bad)} (choose from "
|
||||||
f"{', '.join(setting.choices)})")
|
f"{', '.join(setting.choices)})")
|
||||||
return list(dict.fromkeys(items))
|
return list(dict.fromkeys(items))
|
||||||
elif kind == "freq_list":
|
elif kind == "lockout_list":
|
||||||
from .ranges import RangeError, parse_frequency
|
from .ranges import RangeError, parse_lockout_list
|
||||||
if not raw or raw.lower() in ("(none)", "none", "-"):
|
if not raw or raw.lower() in ("(none)", "none", "-"):
|
||||||
return []
|
return []
|
||||||
out = []
|
try:
|
||||||
for part in raw.replace(";", ",").split(","):
|
return parse_lockout_list(raw)
|
||||||
part = part.strip()
|
except RangeError as exc:
|
||||||
if not part:
|
raise SettingError(str(exc)) from None
|
||||||
continue
|
|
||||||
try:
|
|
||||||
out.append(parse_frequency(part))
|
|
||||||
except RangeError as exc:
|
|
||||||
raise SettingError(str(exc)) from None
|
|
||||||
return out
|
|
||||||
else:
|
else:
|
||||||
raise SettingError(f"unsupported setting kind {kind!r}")
|
raise SettingError(f"unsupported setting kind {kind!r}")
|
||||||
except SettingError:
|
except SettingError:
|
||||||
|
|
@ -578,8 +583,8 @@ def format_value(setting: Setting, value) -> str:
|
||||||
return "automatic" if value is None else f"{value:g} {setting.unit}".strip()
|
return "automatic" if value is None else f"{value:g} {setting.unit}".strip()
|
||||||
if kind == "accept_list":
|
if kind == "accept_list":
|
||||||
return ", ".join(value) if value else "(nothing)"
|
return ", ".join(value) if value else "(nothing)"
|
||||||
if kind == "freq_list":
|
if kind == "lockout_list":
|
||||||
return ", ".join(fmt_hz(v) for v in value) if value else "(none)"
|
return ", ".join(lk.describe() for lk in value) if value else "(none)"
|
||||||
if kind == "gain":
|
if kind == "gain":
|
||||||
return "auto" if isinstance(value, str) else f"{float(value):g} dB"
|
return "auto" if isinstance(value, str) else f"{float(value):g} dB"
|
||||||
if kind == "direct":
|
if kind == "direct":
|
||||||
|
|
@ -631,7 +636,7 @@ def add_arguments(parser) -> None:
|
||||||
section.add_argument(*s.off_flags, dest=s.dest,
|
section.add_argument(*s.off_flags, dest=s.dest,
|
||||||
action="store_false", default=None,
|
action="store_false", default=None,
|
||||||
help=f"do not {s.help}")
|
help=f"do not {s.help}")
|
||||||
elif s.kind == "freq_list":
|
elif s.kind == "lockout_list":
|
||||||
section.add_argument(*s.flags, dest=s.dest, action="append",
|
section.add_argument(*s.flags, dest=s.dest, action="append",
|
||||||
default=None,
|
default=None,
|
||||||
metavar=s.metavar or "VALUE",
|
metavar=s.metavar or "VALUE",
|
||||||
|
|
@ -653,7 +658,7 @@ def apply_args(cfg, args) -> list[str]:
|
||||||
continue
|
continue
|
||||||
if s.kind == "bool":
|
if s.kind == "bool":
|
||||||
value = bool(raw)
|
value = bool(raw)
|
||||||
elif s.kind == "freq_list":
|
elif s.kind == "lockout_list":
|
||||||
value = []
|
value = []
|
||||||
for item in (raw if isinstance(raw, list) else [raw]):
|
for item in (raw if isinstance(raw, list) else [raw]):
|
||||||
value.extend(parse_value(s, item))
|
value.extend(parse_value(s, item))
|
||||||
|
|
|
||||||
|
|
@ -310,7 +310,7 @@ def edit_setting(console: Console, setting: st.Setting, cfg: ScanConfig) -> bool
|
||||||
while True:
|
while True:
|
||||||
raw = _ask(console, f" [bold]{setting.label}[/bold] [grey62]({hint})"
|
raw = _ask(console, f" [bold]{setting.label}[/bold] [grey62]({hint})"
|
||||||
f"[/grey62]", st.format_value(setting, current)
|
f"[/grey62]", st.format_value(setting, current)
|
||||||
if setting.kind not in ("freq_list", "accept_list")
|
if setting.kind not in ("lockout_list", "accept_list")
|
||||||
else "")
|
else "")
|
||||||
if raw.strip() == "" or raw == st.format_value(setting, current):
|
if raw.strip() == "" or raw == st.format_value(setting, current):
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -516,3 +516,54 @@ def test_combined_files_are_padded_too(tmp_path):
|
||||||
log.add(146_520_000.0, np.full(1600, 0.2, dtype=np.float32), 16000)
|
log.add(146_520_000.0, np.full(1600, 0.2, dtype=np.float32), 16000)
|
||||||
names = sorted(p.name for p in tmp_path.glob("*.wav"))
|
names = sorted(p.name for p in tmp_path.glob("*.wav"))
|
||||||
assert names == ["0146.520000MHz.wav", "1090.000000MHz.wav"]
|
assert names == ["0146.520000MHz.wav", "1090.000000MHz.wav"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_span_of_spectrum_can_be_locked_out(tmp_path):
|
||||||
|
"""Not just single frequencies: a whole noisy stretch, as one entry."""
|
||||||
|
tx = [V(146_520_000, "nfm", 0.4, 12_500, "voice"),
|
||||||
|
V(147_000_000, "nfm", 0.4, 12_500, "voice")]
|
||||||
|
s, hits = run_scan(tmp_path, tx, "146.4M-147.1M",
|
||||||
|
lockout=["146.4M-146.8M"], max_cycles=3)
|
||||||
|
assert hits, "nothing recorded at all"
|
||||||
|
assert all(h.frequency > 146_800_000 for h in hits), \
|
||||||
|
[h.frequency for h in hits]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_lock_out_made_during_a_scan_is_remembered(tmp_path):
|
||||||
|
"""It goes back to the settings file, so the next run starts with it."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from bandsaunter.config import load_default, save_default
|
||||||
|
|
||||||
|
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
|
||||||
|
output_dir=str(tmp_path), record_seconds=4.0)
|
||||||
|
save_default(cfg, tmp_path)
|
||||||
|
loaded, path = load_default(tmp_path)
|
||||||
|
|
||||||
|
s = Scanner(loaded, device=SimulatedDevice().open())
|
||||||
|
s.prepare()
|
||||||
|
s.lockout(146_520_000.0)
|
||||||
|
|
||||||
|
saved = yaml.safe_load(path.read_text())
|
||||||
|
assert saved["lockout"] == [{"start": 146_520_000.0, "stop": 146_520_000.0}]
|
||||||
|
# Only that one key: the rest of the file is as it was.
|
||||||
|
assert saved["record_seconds"] == 4.0
|
||||||
|
again, _ = load_default(tmp_path)
|
||||||
|
assert [lk.start for lk in again.lockout] == [146_520_000.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_lock_outs_can_be_kept_to_the_current_run(tmp_path):
|
||||||
|
from bandsaunter.config import load_default, save_default
|
||||||
|
|
||||||
|
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
|
||||||
|
output_dir=str(tmp_path), save_lockouts=False)
|
||||||
|
save_default(cfg, tmp_path)
|
||||||
|
loaded, path = load_default(tmp_path)
|
||||||
|
|
||||||
|
s = Scanner(loaded, device=SimulatedDevice().open())
|
||||||
|
s.prepare()
|
||||||
|
s.lockout(146_520_000.0)
|
||||||
|
|
||||||
|
assert s._is_locked(146_520_000.0), "still locked out for this run"
|
||||||
|
again, _ = load_default(tmp_path)
|
||||||
|
assert not again.lockout, "but not written back"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import pytest
|
||||||
from bandsaunter import settings as st
|
from bandsaunter import settings as st
|
||||||
from bandsaunter.cli import build_parser
|
from bandsaunter.cli import build_parser
|
||||||
from bandsaunter.config import ScanConfig
|
from bandsaunter.config import ScanConfig
|
||||||
|
from bandsaunter.ranges import Lockout
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -65,7 +66,7 @@ def test_defaults_round_trip_through_parse_and_format():
|
||||||
if s.key in ("record_seconds", "max_record_seconds",
|
if s.key in ("record_seconds", "max_record_seconds",
|
||||||
"max_runtime_seconds", "max_cycles") and not value:
|
"max_runtime_seconds", "max_cycles") and not value:
|
||||||
continue # shown as "no limit", typed back as 0
|
continue # shown as "no limit", typed back as 0
|
||||||
if s.kind == "freq_list" and not value:
|
if s.kind == "lockout_list" and not value:
|
||||||
continue # shown as "(none)"
|
continue # shown as "(none)"
|
||||||
back = st.parse_value(s, shown)
|
back = st.parse_value(s, shown)
|
||||||
assert back == value, f"{s.key}: {value!r} -> {shown!r} -> {back!r}"
|
assert back == value, f"{s.key}: {value!r} -> {shown!r} -> {back!r}"
|
||||||
|
|
@ -83,7 +84,10 @@ def test_defaults_round_trip_through_parse_and_format():
|
||||||
("save_iq", "n", False),
|
("save_iq", "n", False),
|
||||||
("accept", "voice, cw", ["voice", "cw"]),
|
("accept", "voice, cw", ["voice", "cw"]),
|
||||||
("accept", "voice cw digital", ["voice", "cw", "digital"]),
|
("accept", "voice cw digital", ["voice", "cw", "digital"]),
|
||||||
("lockout", "162.55M, 146.52M", [162_550_000.0, 146_520_000.0]),
|
("lockout", "162.55M, 146.52M", [Lockout(162_550_000.0),
|
||||||
|
Lockout(146_520_000.0)]),
|
||||||
|
("lockout", "162.55M, 450M-455M", [Lockout(162_550_000.0),
|
||||||
|
Lockout(450_000_000.0, 455_000_000.0)]),
|
||||||
("detector_bias_db", "", None),
|
("detector_bias_db", "", None),
|
||||||
("detector_bias_db", "6", 6.0),
|
("detector_bias_db", "6", 6.0),
|
||||||
("sample_rate", "2048000", 2_048_000),
|
("sample_rate", "2048000", 2_048_000),
|
||||||
|
|
@ -154,7 +158,7 @@ def test_repeated_lockout_flags_accumulate():
|
||||||
"--lockout", "146.52M"])
|
"--lockout", "146.52M"])
|
||||||
cfg = ScanConfig()
|
cfg = ScanConfig()
|
||||||
st.apply_args(cfg, args)
|
st.apply_args(cfg, args)
|
||||||
assert cfg.lockout == [162_550_000.0, 146_520_000.0]
|
assert cfg.lockout == [Lockout(162_550_000.0), Lockout(146_520_000.0)]
|
||||||
|
|
||||||
|
|
||||||
def test_help_text_renders_for_every_entry():
|
def test_help_text_renders_for_every_entry():
|
||||||
|
|
@ -183,7 +187,7 @@ def test_saved_settings_round_trip(tmp_path):
|
||||||
cfg.hang_seconds = 6.0
|
cfg.hang_seconds = 6.0
|
||||||
cfg.record_seconds = 0.0
|
cfg.record_seconds = 0.0
|
||||||
cfg.accept = ["voice", "cw"]
|
cfg.accept = ["voice", "cw"]
|
||||||
cfg.lockout = [162_550_000.0]
|
cfg.lockout = [Lockout(162_550_000.0)]
|
||||||
cfg.gain = 28.0
|
cfg.gain = 28.0
|
||||||
save_default(cfg, tmp_path)
|
save_default(cfg, tmp_path)
|
||||||
|
|
||||||
|
|
@ -192,7 +196,7 @@ def test_saved_settings_round_trip(tmp_path):
|
||||||
assert back.hang_seconds == 6.0
|
assert back.hang_seconds == 6.0
|
||||||
assert back.record_seconds == 0.0
|
assert back.record_seconds == 0.0
|
||||||
assert back.accept == ["voice", "cw"]
|
assert back.accept == ["voice", "cw"]
|
||||||
assert back.lockout == [162_550_000.0]
|
assert back.lockout == [Lockout(162_550_000.0)]
|
||||||
assert back.gain == 28.0
|
assert back.gain == 28.0
|
||||||
assert [r.label for r in back.ranges] == [r.label for r in cfg.ranges]
|
assert [r.label for r in back.ranges] == [r.label for r in cfg.ranges]
|
||||||
|
|
||||||
|
|
@ -210,8 +214,8 @@ def test_every_setting_survives_a_save_and_load(tmp_path):
|
||||||
value = [c for c in s.choices if c != current][0]
|
value = [c for c in s.choices if c != current][0]
|
||||||
elif s.kind == "accept_list":
|
elif s.kind == "accept_list":
|
||||||
value = ["cw"]
|
value = ["cw"]
|
||||||
elif s.kind == "freq_list":
|
elif s.kind == "lockout_list":
|
||||||
value = [146_520_000.0]
|
value = [Lockout(146_520_000.0)]
|
||||||
elif s.kind in ("text", "path"):
|
elif s.kind in ("text", "path"):
|
||||||
value = "somewhere"
|
value = "somewhere"
|
||||||
elif s.kind == "gain":
|
elif s.kind == "gain":
|
||||||
|
|
@ -317,8 +321,8 @@ def a_different_value(s, current):
|
||||||
return 2
|
return 2
|
||||||
if s.kind == "path":
|
if s.kind == "path":
|
||||||
return "/tmp/somewhere-else"
|
return "/tmp/somewhere-else"
|
||||||
if s.kind == "freq_list":
|
if s.kind == "lockout_list":
|
||||||
return [162_550_000.0, 146_520_000.0]
|
return [Lockout(162_550_000.0), Lockout(450_000_000.0, 455_000_000.0)]
|
||||||
if s.kind == "text":
|
if s.kind == "text":
|
||||||
return f"probe-{s.key}"
|
return f"probe-{s.key}"
|
||||||
raise AssertionError(f"no test value for kind {s.kind!r} ({s.key})")
|
raise AssertionError(f"no test value for kind {s.kind!r} ({s.key})")
|
||||||
|
|
@ -415,3 +419,44 @@ def test_no_setting_is_saved_but_never_read():
|
||||||
unread = [s.key for s in st.SETTINGS
|
unread = [s.key for s in st.SETTINGS
|
||||||
if not re.search(rf"\bcfg\.{s.key}\b|\bconfig\.{s.key}\b", src)]
|
if not re.search(rf"\bcfg\.{s.key}\b|\bconfig\.{s.key}\b", src)]
|
||||||
assert not unread, f"settings nothing ever reads: {unread}"
|
assert not unread, f"settings nothing ever reads: {unread}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lock-outs: spans, and remembering them
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text,expected", [
|
||||||
|
("162.55M", [(162_550_000.0, 162_550_000.0)]),
|
||||||
|
("162.55M, 146.52M", [(162_550_000.0, 162_550_000.0),
|
||||||
|
(146_520_000.0, 146_520_000.0)]),
|
||||||
|
("450M-455M", [(450_000_000.0, 455_000_000.0)]),
|
||||||
|
("450-455M", [(450_000_000.0, 455_000_000.0)]), # unit carries over
|
||||||
|
("88M to 108M", [(88_000_000.0, 108_000_000.0)]),
|
||||||
|
("450M..455M", [(450_000_000.0, 455_000_000.0)]),
|
||||||
|
("162.55M, 450M-455M; 88M-108M", [(162_550_000.0, 162_550_000.0),
|
||||||
|
(450_000_000.0, 455_000_000.0),
|
||||||
|
(88_000_000.0, 108_000_000.0)]),
|
||||||
|
("455M-450M", [(450_000_000.0, 455_000_000.0)]), # written backwards
|
||||||
|
])
|
||||||
|
def test_lockouts_accept_lists_and_spans(text, expected):
|
||||||
|
got = st.parse_value(st.by_key("lockout"), text)
|
||||||
|
assert [(lk.start, lk.stop) for lk in got] == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_span_is_taken_as_written_and_a_point_is_widened():
|
||||||
|
point, span = st.parse_value(st.by_key("lockout"), "146.52M, 450M-455M")
|
||||||
|
assert point.interval(12_500) == (146_513_750.0, 146_526_250.0)
|
||||||
|
assert span.interval(12_500) == (450_000_000.0, 455_000_000.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_older_settings_files_keep_their_bare_numbers(tmp_path):
|
||||||
|
"""Lock-outs used to be plain frequencies, and still mean the same thing."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from bandsaunter.config import load_config
|
||||||
|
|
||||||
|
(tmp_path / "old.yaml").write_text(
|
||||||
|
yaml.safe_dump({"lockout": [162_550_000.0, 146_520_000.0]}))
|
||||||
|
cfg = load_config("old", tmp_path)
|
||||||
|
assert [lk.start for lk in cfg.lockout] == [162_550_000.0, 146_520_000.0]
|
||||||
|
assert not any(lk.is_span for lk in cfg.lockout)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue