Recognise trunking control channels, and refuse to sit on them

A trunked system keeps one frequency transmitting a data stream around
the clock so its radios know where each conversation has been put.  There
is no speech on it and it never stops, which makes it the strongest and
most useless signal in the band: the scanner parked on 856.561 MHz for
the full record limit, saved four minutes of buzzing, and found it again
on the next sweep.

Five signatures, matched against a constant-envelope stream that never
pauses: 3600 baud two-level (Motorola SMARTNET/SmartZone), 9600 (EDACS),
1200 (MPT-1327), 4800 four-level (P25 or DMR Tier III), 2400 (NXDN).
The first two are believed at once -- nothing else sends at those rates
without pausing.  The rest share their shape with a digital voice call on
the same system, so they wait for the carrier to run unbroken past
--control-seconds, longer than a conversation goes without a breath.
Being in a trunked allocation raises confidence but is never required;
trunking is licensed on business pairs all over the spectrum.

One is named on screen, abandoned within a second or so, and its capture
deleted.  --keep-control records them for a decoder; --lockout-control
writes them into the lock-out list.

Three things had to be fixed to get there.

The simulator's "pseudo-random" symbols were a counter: multiplying the
symbol index by an odd constant and taking it modulo the level count
returns the low bits, so two-level FSK came out 0,1,0,1.  Every FSK test
in the suite was measuring a tone.  Its FSK is now shaped the way GFSK
and C4FM shape a stream, too, square-edged keying being a signal no
licensed transmitter would radiate.

The symbol-rate estimator locked onto harmonics -- 3600 baud read as
18000 -- because a transition impulse train is a comb of equal lines; it
now walks down to the fundamental.  The squared envelope is no longer a
candidate: it is not a transition signal, and its DC lobe made every
random OOK signal measure ninety baud.  The search starts at 200 Hz
rather than 40, below which it was reading drift, which is how a bare
carrier was awarded a symbol rate.  And a clean two-level signal counted
zero discriminator levels, because its modes land in the first and last
histogram bin, where find_peaks cannot see them.

Separately: locking out a frequency wrote to the settings file even under
--no-config, which has no settings file by definition.  It now writes
only where it read from, and --simulate never writes at all -- an
invented frequency would sit in a real config for ever, skipping whatever
genuine signal happened to land near it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
The Dust Council 2026-08-22 14:22:49 -07:00
parent ba6c925351
commit 4a272eb1d5
14 changed files with 984 additions and 53 deletions

View file

@ -83,6 +83,11 @@ class ScanConfig:
verify_max_seconds: float = 6.0 # give up on a contentless capture by here
max_detections_per_step: int = 4
# -- trunking control channels --------------------------------------
skip_control: bool = True # spot them, abandon them, move on
control_seconds: float = 20.0 # unbroken carrier a look-alike must hold
lockout_control: bool = False # also add them to the lock-out list
# -- radio ----------------------------------------------------------
device_index: int = 0
sample_rate: int = 2_048_000
@ -288,10 +293,16 @@ def remember_lockouts(cfg: "ScanConfig",
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.
A run started with --no-config has no settings file, and writing one
anyway would be the opposite of what was asked: the flag exists to leave
the saved settings alone. So without a source to write back to, nothing
is written.
"""
path = getattr(cfg, "_source_path", None)
path = Path(path) if path else (Path(directory or DEFAULT_CONFIG_DIR)
/ "config.yaml")
source = getattr(cfg, "_source_path", None)
if not source and directory is None:
return None
path = Path(source) if source else (Path(directory) / "config.yaml")
try:
data = {}
if path.exists():