Two additions, both about turning a number into something meaningful. A band column. Next to every frequency -- on the live display, in the line-per-hit output, in saunterbrowse's list and details -- is the name of the band it falls in. 421 MHz is the 70 cm amateur band, and being told so is quicker than remembering where the edges are. The names come from the existing preset table, so there is one band plan to keep right rather than two, but naming is not the job that table was shaped for: several presets cover any frequency, some of them whole-tuner sweeps that say nothing. So the candidates are ranked. Sweeps and the "-complete" duplicates are dropped outright. The narrowest of what is left wins, because it says the most -- 146.52 MHz comes back as the 2 m simplex calling channel rather than as the whole 2 m band. Two exceptions where the narrowest would be the wrong answer: ISM yields to the allocation it shares (433.92 is 70 cm first, 915 is 33 cm first), and shortwave broadcast yields to amateur where the two overlap, because 3.9-4.0 and 7.2-7.3 MHz are Region 1 and 3 broadcast but Region 2 amateur, and this plan is documented as Region 2. 6 MHz really is 49 m shortwave and is left alone. The name is written into each capture's sidecar, so it travels with the recording and an edit to the plan later cannot rewrite history, and saunterbrowse searches on it: /70 cm finds the band without anyone having to remember 420-450 MHz. A map. A licence says where its holder is, so a list of callsigns is also a map. Callsigns heard during a scan are now looked up as the transcripts come in, announced on the display, and written to callsigns.kml in the output directory; saunterbrowse --kml builds the same file from recordings already on disk, and the two continue one map rather than starting two. One placemark per station, not one per transmission: the same repeater heard twenty times in an evening is one operator, and twenty pins on one rooftop would say less than one. Each pin carries the callsign, the licensee, the town, the grid square, and every frequency and time it was heard on. The file is read back on open and added to, so later scans build it up rather than replacing it. Where a licence has no coordinates the grid square's centre is used and the placemark says so -- a square is kilometres across where an address is a street. A callsign with no licence at all is still recorded, in a folder that starts switched off, because that a station was heard is worth keeping even when nothing says where. A file already there that is not readable as KML is never overwritten. Also fixed along the way: - The hit list's "no signals recorded yet" placeholder was one cell short of its row, so it landed in the SNR column and wrapped, making the panel taller than the layout had budgeted for and scrolling the display off a short terminal. The identification column can no longer wrap either, which is what _hit_capacity has always assumed. - Licence lookups now record coordinates. The cache is versioned so that entries written before this are asked about again, rather than pinning every station to its grid square for good. - CallsignBook.wait dropped joined threads; an all-night scan calls it after every transcript and the list only ever grew. - Tests redirect XDG_CACHE_HOME, so a run no longer reads or writes the real lookup cache. 676 -> 761 tests.
692 lines
33 KiB
Python
Executable file
692 lines
33 KiB
Python
Executable file
"""US band plan presets.
|
|
|
|
Each preset is a tunable span plus enough metadata for the scanner to pick a
|
|
sensible demodulator, channel spacing and bandwidth without being told.
|
|
Frequencies are in Hz. Coverage follows the FCC allocations and the common
|
|
NA channel plans (ITU Region 2).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
|
|
__all__ = ["BandPreset", "PRESETS", "CATEGORIES", "by_key", "search",
|
|
"in_category", "presets_covering", "expand_preset",
|
|
"BandLabel", "label_for", "band_label", "band_name",
|
|
"band_names"]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BandPreset:
|
|
key: str
|
|
name: str
|
|
category: str
|
|
start: float
|
|
stop: float
|
|
step: float = 12_500.0 # channel spacing / scan resolution
|
|
mode: str = "nfm" # demodulator hint: nfm wfm am usb lsb cw raw
|
|
bandwidth: float = 12_500.0 # nominal signal bandwidth
|
|
note: str = ""
|
|
tags: tuple[str, ...] = field(default_factory=tuple)
|
|
# A preset may instead stand for a set of others, so that scattered
|
|
# segments -- every CW allocation, say -- can be picked in one go.
|
|
members: tuple[str, ...] = field(default_factory=tuple)
|
|
|
|
@property
|
|
def is_group(self) -> bool:
|
|
return bool(self.members)
|
|
|
|
def expand(self) -> list["BandPreset"]:
|
|
"""The presets this one actually scans: itself, or its members."""
|
|
if not self.members:
|
|
return [self]
|
|
out = []
|
|
for key in self.members:
|
|
member = _BY_KEY.get(key)
|
|
if member is not None:
|
|
out.extend(member.expand())
|
|
return out
|
|
|
|
@property
|
|
def span(self) -> float:
|
|
return self.stop - self.start
|
|
|
|
@property
|
|
def needs_direct_sampling(self) -> bool:
|
|
return self.stop < 24_000_000
|
|
|
|
def describe(self) -> str:
|
|
return f"{self.name} {fmt_hz(self.start)}-{fmt_hz(self.stop)} [{self.mode}]"
|
|
|
|
|
|
def fmt_hz(hz: float) -> str:
|
|
"""Render a frequency the way a radio operator would write it."""
|
|
hz = float(hz)
|
|
if hz >= 1e9:
|
|
return f"{hz/1e9:.6f}".rstrip("0").rstrip(".") + " GHz"
|
|
if hz >= 1e6:
|
|
s = f"{hz/1e6:.6f}".rstrip("0").rstrip(".")
|
|
return f"{s} MHz"
|
|
if hz >= 1e3:
|
|
s = f"{hz/1e3:.4f}".rstrip("0").rstrip(".")
|
|
return f"{s} kHz"
|
|
return f"{hz:.0f} Hz"
|
|
|
|
|
|
_P = BandPreset
|
|
|
|
PRESETS: tuple[BandPreset, ...] = (
|
|
|
|
# ------------------------------------------------------------------
|
|
# HF -- requires direct sampling (Q branch) on an RTL2832 dongle.
|
|
# ------------------------------------------------------------------
|
|
_P("am-broadcast", "AM Broadcast Band", "HF / Shortwave",
|
|
530_000, 1_710_000, 10_000, "am", 10_000,
|
|
"US MW broadcast, 10 kHz channel spacing", ("broadcast", "hf")),
|
|
_P("160m", "160 m Amateur (Top Band)", "Amateur Radio",
|
|
1_800_000, 2_000_000, 500, "lsb", 2_800,
|
|
"CW/digital low, phone above 1.843", ("ham", "hf")),
|
|
_P("160m-cw", "160 m CW", "Amateur Radio",
|
|
1_800_000, 1_840_000, 200, "cw", 500,
|
|
"CW and digital segment", ("ham", "hf", "cw")),
|
|
_P("120m-swbc", "120 m Shortwave Broadcast", "HF / Shortwave",
|
|
2_300_000, 2_495_000, 5_000, "am", 9_000, "Tropical band", ("swl", "hf")),
|
|
_P("marine-hf-2mhz", "Marine HF 2 MHz", "Marine",
|
|
2_000_000, 2_850_000, 1_000, "usb", 2_800,
|
|
"Includes 2182 kHz distress", ("marine", "hf")),
|
|
_P("wwv", "WWV / WWVH / CHU Time Signals", "Utility / Time",
|
|
2_490_000, 20_010_000, 1_000, "am", 6_000,
|
|
"2.5/5/10/15/20 MHz WWV, 3.33/7.85/14.67 CHU", ("utility", "hf")),
|
|
_P("90m-swbc", "90 m Shortwave Broadcast", "HF / Shortwave",
|
|
3_200_000, 3_400_000, 5_000, "am", 9_000, "Tropical band", ("swl", "hf")),
|
|
_P("80m", "80/75 m Amateur", "Amateur Radio",
|
|
3_500_000, 4_000_000, 500, "lsb", 2_800,
|
|
"CW 3.500-3.600, phone 3.800-4.000", ("ham", "hf")),
|
|
_P("80m-cw", "80 m CW / Digital", "Amateur Radio",
|
|
3_500_000, 3_600_000, 200, "cw", 500,
|
|
"CW segment, FT8 at 3.573", ("ham", "hf", "cw")),
|
|
_P("75m-swbc", "75 m Shortwave Broadcast", "HF / Shortwave",
|
|
3_900_000, 4_000_000, 5_000, "am", 9_000, "", ("swl", "hf")),
|
|
_P("60m", "60 m Amateur (channelised)", "Amateur Radio",
|
|
5_330_500, 5_405_000, 100, "usb", 2_800,
|
|
"Five fixed US channels", ("ham", "hf")),
|
|
_P("49m-swbc", "49 m Shortwave Broadcast", "HF / Shortwave",
|
|
5_900_000, 6_200_000, 5_000, "am", 9_000, "", ("swl", "hf")),
|
|
_P("aero-hf", "HF Aeronautical (oceanic)", "Aviation",
|
|
5_450_000, 6_700_000, 1_000, "usb", 2_800,
|
|
"MWARA / VOLMET / SELCAL", ("aviation", "hf")),
|
|
_P("40m", "40 m Amateur", "Amateur Radio",
|
|
7_000_000, 7_300_000, 500, "lsb", 2_800,
|
|
"CW 7.000-7.125, phone 7.125-7.300", ("ham", "hf")),
|
|
_P("40m-cw", "40 m CW / Digital", "Amateur Radio",
|
|
7_000_000, 7_125_000, 200, "cw", 500,
|
|
"CW segment, FT8 at 7.074", ("ham", "hf", "cw")),
|
|
_P("41m-swbc", "41 m Shortwave Broadcast", "HF / Shortwave",
|
|
7_200_000, 7_600_000, 5_000, "am", 9_000, "", ("swl", "hf")),
|
|
_P("31m-swbc", "31 m Shortwave Broadcast", "HF / Shortwave",
|
|
9_400_000, 9_900_000, 5_000, "am", 9_000, "", ("swl", "hf")),
|
|
_P("30m", "30 m Amateur (CW/digital only)", "Amateur Radio",
|
|
10_100_000, 10_150_000, 200, "cw", 500,
|
|
"No phone; FT8 at 10.136", ("ham", "hf", "cw")),
|
|
_P("25m-swbc", "25 m Shortwave Broadcast", "HF / Shortwave",
|
|
11_600_000, 12_100_000, 5_000, "am", 9_000, "", ("swl", "hf")),
|
|
_P("22m-swbc", "22 m Shortwave Broadcast", "HF / Shortwave",
|
|
13_570_000, 13_870_000, 5_000, "am", 9_000, "", ("swl", "hf")),
|
|
_P("20m", "20 m Amateur", "Amateur Radio",
|
|
14_000_000, 14_350_000, 500, "usb", 2_800,
|
|
"CW 14.000-14.150, phone above", ("ham", "hf")),
|
|
_P("20m-cw", "20 m CW / Digital", "Amateur Radio",
|
|
14_000_000, 14_150_000, 200, "cw", 500,
|
|
"CW segment, FT8 at 14.074", ("ham", "hf", "cw")),
|
|
_P("19m-swbc", "19 m Shortwave Broadcast", "HF / Shortwave",
|
|
15_100_000, 15_830_000, 5_000, "am", 9_000, "", ("swl", "hf")),
|
|
_P("17m", "17 m Amateur", "Amateur Radio",
|
|
18_068_000, 18_168_000, 500, "usb", 2_800, "", ("ham", "hf")),
|
|
_P("17m-cw", "17 m CW / Digital", "Amateur Radio",
|
|
18_068_000, 18_110_000, 200, "cw", 500,
|
|
"CW segment, FT8 at 18.100", ("ham", "hf", "cw")),
|
|
_P("16m-swbc", "16 m Shortwave Broadcast", "HF / Shortwave",
|
|
17_480_000, 17_900_000, 5_000, "am", 9_000, "", ("swl", "hf")),
|
|
_P("15m", "15 m Amateur", "Amateur Radio",
|
|
21_000_000, 21_450_000, 500, "usb", 2_800, "", ("ham", "hf")),
|
|
_P("15m-cw", "15 m CW / Digital", "Amateur Radio",
|
|
21_000_000, 21_200_000, 200, "cw", 500,
|
|
"CW segment, FT8 at 21.074", ("ham", "hf", "cw")),
|
|
_P("13m-swbc", "13 m Shortwave Broadcast", "HF / Shortwave",
|
|
21_450_000, 21_850_000, 5_000, "am", 9_000, "", ("swl", "hf")),
|
|
_P("12m", "12 m Amateur", "Amateur Radio",
|
|
24_890_000, 24_990_000, 500, "usb", 2_800, "", ("ham", "hf")),
|
|
_P("12m-cw", "12 m CW / Digital", "Amateur Radio",
|
|
24_890_000, 24_930_000, 200, "cw", 500,
|
|
"CW segment, FT8 at 24.915", ("ham", "hf", "cw")),
|
|
_P("cb", "Citizens Band (CB) 11 m", "Land Mobile",
|
|
26_965_000, 27_405_000, 10_000, "am", 8_000,
|
|
"40 channels; ch 19 = 27.185, SSB above ch 35", ("cb",)),
|
|
_P("cb-ssb", "CB SSB / Freeband", "Land Mobile",
|
|
27_235_000, 27_995_000, 5_000, "usb", 2_800, "", ("cb",)),
|
|
_P("10m", "10 m Amateur", "Amateur Radio",
|
|
28_000_000, 29_700_000, 1_000, "usb", 2_800,
|
|
"CW/data low, FM repeaters 29.5-29.7", ("ham", "hf")),
|
|
_P("10m-cw", "10 m CW / Digital", "Amateur Radio",
|
|
28_000_000, 28_300_000, 200, "cw", 500,
|
|
"CW segment, FT8 at 28.074, beacons 28.190-28.300", ("ham", "hf", "cw")),
|
|
_P("10m-fm", "10 m FM Simplex / Repeaters", "Amateur Radio",
|
|
29_500_000, 29_700_000, 20_000, "nfm", 16_000, "", ("ham",)),
|
|
|
|
# ------------------------------------------------------------------
|
|
# VHF low band
|
|
# ------------------------------------------------------------------
|
|
_P("vhf-low-public-safety", "VHF Low Band Public Safety", "Public Safety",
|
|
30_000_000, 50_000_000, 5_000, "nfm", 12_500,
|
|
"State police, forestry, highway crews", ("publicsafety",)),
|
|
_P("mil-lowband", "Military / SINCGARS Low Band", "Military",
|
|
30_000_000, 88_000_000, 25_000, "nfm", 25_000,
|
|
"Tactical FM, mostly frequency hopping", ("military",)),
|
|
_P("6m", "6 m Amateur", "Amateur Radio",
|
|
50_000_000, 54_000_000, 20_000, "nfm", 16_000,
|
|
"CW/SSB 50.0-50.3, FM 52-54", ("ham",)),
|
|
_P("6m-ssb", "6 m Weak Signal (CW/SSB)", "Amateur Radio",
|
|
50_000_000, 50_300_000, 500, "usb", 2_800,
|
|
"50.125 SSB calling, FT8 at 50.313", ("ham", "cw")),
|
|
_P("6m-cw", "6 m CW", "Amateur Radio",
|
|
50_000_000, 50_100_000, 200, "cw", 500,
|
|
"CW segment, beacons 50.060-50.080", ("ham", "cw")),
|
|
_P("rc-radio-control", "R/C Radio Control 72 MHz", "Telemetry / Control",
|
|
72_000_000, 76_000_000, 20_000, "nfm", 10_000,
|
|
"Model aircraft channels 11-60", ("control",)),
|
|
_P("tv-low-vhf", "VHF TV Channels 2-6", "Broadcast",
|
|
54_000_000, 88_000_000, 6_000_000, "raw", 6_000_000,
|
|
"ATSC 8VSB, mostly vacated", ("broadcast",)),
|
|
|
|
# ------------------------------------------------------------------
|
|
# VHF high band
|
|
# ------------------------------------------------------------------
|
|
_P("fm-broadcast", "FM Broadcast Band", "Broadcast",
|
|
88_100_000, 107_900_000, 200_000, "wfm", 180_000,
|
|
"US odd-tenth channel centres", ("broadcast",)),
|
|
_P("aircraft-vhf", "VHF Airband (voice)", "Aviation",
|
|
118_000_000, 137_000_000, 25_000, "am", 12_500,
|
|
"Tower/approach/centre, 8.33 kHz not used in US", ("aviation",)),
|
|
_P("aircraft-vhf-tower", "VHF Airband - Tower & Ground", "Aviation",
|
|
118_000_000, 122_000_000, 25_000, "am", 12_500, "", ("aviation",)),
|
|
_P("aircraft-vhf-unicom", "VHF Airband - UNICOM / CTAF", "Aviation",
|
|
122_000_000, 123_600_000, 25_000, "am", 12_500,
|
|
"122.700-122.975 and 123.000 CTAF", ("aviation",)),
|
|
_P("acars", "ACARS Datalink", "Aviation",
|
|
129_125_000, 136_975_000, 25_000, "am", 12_500,
|
|
"Primary US ACARS on 131.550", ("aviation", "data")),
|
|
_P("satellite-vhf", "VHF Satellite Downlinks", "Satellite",
|
|
136_000_000, 138_000_000, 12_500, "nfm", 40_000,
|
|
"NOAA APT 137.100/137.9125, Meteor, cubesats", ("satellite",)),
|
|
_P("noaa-apt", "NOAA APT Weather Satellites", "Satellite",
|
|
137_000_000, 138_000_000, 25_000, "wfm", 40_000,
|
|
"NOAA-15 137.620, NOAA-18 137.9125, NOAA-19 137.100", ("satellite",)),
|
|
_P("federal-vhf", "Federal Government VHF", "Government",
|
|
138_000_000, 144_000_000, 12_500, "nfm", 12_500,
|
|
"Military land mobile and federal agencies", ("government",)),
|
|
_P("2m", "2 m Amateur", "Amateur Radio",
|
|
144_000_000, 148_000_000, 15_000, "nfm", 16_000,
|
|
"CW/SSB 144.0-144.3, FM simplex/repeaters above", ("ham",)),
|
|
_P("2m-ssb", "2 m Weak Signal (CW/SSB)", "Amateur Radio",
|
|
144_000_000, 144_300_000, 500, "usb", 2_800,
|
|
"144.200 SSB calling, FT8 at 144.174", ("ham", "cw")),
|
|
_P("2m-cw", "2 m CW", "Amateur Radio",
|
|
144_000_000, 144_100_000, 200, "cw", 500,
|
|
"CW segment, EME and beacons at the bottom", ("ham", "cw")),
|
|
_P("2m-simplex", "2 m FM Simplex", "Amateur Radio",
|
|
146_400_000, 147_600_000, 15_000, "nfm", 16_000,
|
|
"146.520 national simplex calling", ("ham",)),
|
|
_P("2m-repeaters", "2 m Repeater Outputs", "Amateur Radio",
|
|
145_100_000, 148_000_000, 15_000, "nfm", 16_000, "", ("ham",)),
|
|
_P("aprs", "APRS / Packet 2 m", "Amateur Radio",
|
|
144_380_000, 144_400_000, 12_500, "nfm", 16_000,
|
|
"144.390 AFSK1200 in North America", ("ham", "data")),
|
|
_P("federal-vhf-2", "Federal Government VHF (upper)", "Government",
|
|
148_000_000, 150_800_000, 12_500, "nfm", 12_500, "", ("government",)),
|
|
_P("vhf-business", "VHF Business / Industrial", "Land Mobile",
|
|
150_800_000, 156_000_000, 7_500, "nfm", 12_500,
|
|
"Itinerant, taxi, utilities, tow", ("business",)),
|
|
_P("murs", "MURS", "Land Mobile",
|
|
151_820_000, 154_600_000, 7_500, "nfm", 12_500,
|
|
"5 licence-free channels: 151.820/.880/.940, 154.570/.600", ("business",)),
|
|
_P("railroad", "Railroad (AAR channels)", "Land Mobile",
|
|
159_810_000, 161_565_000, 7_500, "nfm", 12_500,
|
|
"97 AAR road/yard channels", ("railroad",)),
|
|
_P("marine-vhf", "Marine VHF", "Marine",
|
|
156_000_000, 162_025_000, 25_000, "nfm", 16_000,
|
|
"Ch 16 = 156.800 distress, Ch 13/22A working", ("marine",)),
|
|
_P("ais", "AIS Ship Transponders", "Marine",
|
|
161_975_000, 162_025_000, 25_000, "raw", 25_000,
|
|
"AIS-A 161.975, AIS-B 162.025, 9600 GMSK", ("marine", "data")),
|
|
_P("noaa-weather", "NOAA Weather Radio", "Broadcast",
|
|
162_400_000, 162_550_000, 25_000, "nfm", 16_000,
|
|
"7 channels, 162.400-162.550", ("broadcast",)),
|
|
_P("federal-vhf-3", "Federal VHF (162-174)", "Government",
|
|
162_000_000, 174_000_000, 12_500, "nfm", 12_500,
|
|
"FBI, DHS, Forest Service, NPS", ("government",)),
|
|
_P("vhf-highband-ps", "VHF High Band Public Safety", "Public Safety",
|
|
154_000_000, 160_000_000, 7_500, "nfm", 12_500,
|
|
"Fire, EMS, police, local government", ("publicsafety",)),
|
|
_P("wireless-mics-vhf", "Wireless Microphones VHF", "Audio / Production",
|
|
169_000_000, 172_000_000, 25_000, "nfm", 25_000,
|
|
"Travelling band and assistive listening", ("production",)),
|
|
_P("tv-high-vhf", "VHF TV Channels 7-13", "Broadcast",
|
|
174_000_000, 216_000_000, 6_000_000, "raw", 6_000_000,
|
|
"ATSC 8VSB", ("broadcast",)),
|
|
_P("dab-us", "VHF 216-225 (misc)", "Broadcast",
|
|
216_000_000, 225_000_000, 25_000, "nfm", 25_000,
|
|
"Telemetry, AMTS maritime", ("misc",)),
|
|
|
|
# ------------------------------------------------------------------
|
|
# UHF
|
|
# ------------------------------------------------------------------
|
|
_P("mil-uhf-air", "Military UHF Aircraft", "Aviation",
|
|
225_000_000, 400_000_000, 25_000, "am", 12_500,
|
|
"243.000 guard, air refuelling, ranges", ("military", "aviation")),
|
|
_P("milsat-uhf", "UHF SATCOM Downlinks", "Satellite",
|
|
240_000_000, 270_000_000, 5_000, "nfm", 25_000,
|
|
"FLTSATCOM / UFO downlinks", ("satellite", "military")),
|
|
_P("1.25m", "1.25 m (220 MHz) Amateur", "Amateur Radio",
|
|
222_000_000, 225_000_000, 20_000, "nfm", 16_000, "", ("ham",)),
|
|
_P("1.25m-weak", "1.25 m Weak Signal (CW/SSB)", "Amateur Radio",
|
|
222_000_000, 222_150_000, 500, "usb", 2_800,
|
|
"222.100 SSB calling", ("ham", "cw")),
|
|
_P("federal-uhf", "Federal Government UHF", "Government",
|
|
406_100_000, 420_000_000, 12_500, "nfm", 12_500,
|
|
"Federal land mobile", ("government",)),
|
|
_P("radiosonde", "Radiosondes / Weather Balloons", "Telemetry / Control",
|
|
400_000_000, 406_000_000, 10_000, "nfm", 15_000,
|
|
"RS41, DFM, iMet -- 403 MHz is the US cluster", ("telemetry",)),
|
|
_P("70cm", "70 cm Amateur", "Amateur Radio",
|
|
420_000_000, 450_000_000, 12_500, "nfm", 16_000,
|
|
"Repeaters 440-450, ATV low", ("ham",)),
|
|
_P("70cm-cw", "70 cm CW / EME", "Amateur Radio",
|
|
432_000_000, 432_100_000, 200, "cw", 500,
|
|
"CW and moonbounce at the bottom of the band", ("ham", "cw")),
|
|
_P("70cm-weak", "70 cm Weak Signal (SSB)", "Amateur Radio",
|
|
432_100_000, 432_400_000, 500, "usb", 2_800,
|
|
"432.100 SSB calling", ("ham",)),
|
|
_P("70cm-fm", "70 cm FM Repeaters", "Amateur Radio",
|
|
440_000_000, 450_000_000, 12_500, "nfm", 16_000,
|
|
"Repeater outputs and simplex", ("ham",)),
|
|
_P("70cm-simplex", "70 cm FM Simplex", "Amateur Radio",
|
|
445_000_000, 447_000_000, 12_500, "nfm", 16_000,
|
|
"446.000 national simplex calling", ("ham",)),
|
|
_P("uhf-business", "UHF Business / Industrial", "Land Mobile",
|
|
450_000_000, 470_000_000, 6_250, "nfm", 12_500,
|
|
"Includes 464/469 itinerant 'colour dot' channels", ("business",)),
|
|
_P("uhf-public-safety", "UHF Public Safety", "Public Safety",
|
|
453_000_000, 460_000_000, 6_250, "nfm", 12_500, "", ("publicsafety",)),
|
|
_P("gmrs", "GMRS / FRS", "Land Mobile",
|
|
462_550_000, 467_725_000, 12_500, "nfm", 12_500,
|
|
"22 shared channels + 8 GMRS repeater pairs", ("consumer",)),
|
|
_P("frs-simplex", "FRS Simplex Channels", "Land Mobile",
|
|
462_562_500, 467_712_500, 12_500, "nfm", 12_500,
|
|
"Licence-free handhelds", ("consumer",)),
|
|
_P("dot-itinerant", "Itinerant 'Colour Dot' Channels", "Land Mobile",
|
|
464_500_000, 469_562_500, 12_500, "nfm", 12_500,
|
|
"Red/Blue/Green/Purple dot business itinerant", ("business",)),
|
|
_P("uhf-tv", "UHF TV Channels 14-36", "Broadcast",
|
|
470_000_000, 608_000_000, 6_000_000, "raw", 6_000_000,
|
|
"ATSC 1.0/3.0", ("broadcast",)),
|
|
_P("t-band", "T-Band Public Safety (470-512)", "Public Safety",
|
|
470_000_000, 512_000_000, 12_500, "nfm", 12_500,
|
|
"Only in 11 major metro areas", ("publicsafety",)),
|
|
_P("wireless-mics-uhf", "Wireless Microphones UHF", "Audio / Production",
|
|
512_000_000, 608_000_000, 25_000, "nfm", 200_000,
|
|
"Shure/Sennheiser IEM and mics in the TV band", ("production",)),
|
|
_P("600-duplex-gap", "600 MHz Duplex Gap / White Space", "Telecom",
|
|
614_000_000, 698_000_000, 100_000, "raw", 200_000,
|
|
"T-Mobile 600 LTE, wireless mics in the gap", ("telecom",)),
|
|
_P("700-public-safety", "700 MHz Public Safety", "Public Safety",
|
|
763_000_000, 806_000_000, 12_500, "nfm", 12_500,
|
|
"P25 Phase I/II narrowband + FirstNet LTE", ("publicsafety", "trunked")),
|
|
_P("800-public-safety", "800 MHz Public Safety / SMR", "Public Safety",
|
|
806_000_000, 824_000_000, 12_500, "nfm", 12_500,
|
|
"NPSPAC, Motorola trunked systems", ("publicsafety", "trunked")),
|
|
_P("800-trunked", "800 MHz Trunked Downlinks", "Public Safety",
|
|
851_000_000, 869_000_000, 12_500, "nfm", 12_500,
|
|
"Repeater outputs for 806-824 inputs", ("trunked",)),
|
|
_P("900-smr", "900 MHz SMR / Business", "Land Mobile",
|
|
896_000_000, 940_000_000, 12_500, "nfm", 12_500,
|
|
"Includes 935-940 trunked outputs", ("business", "trunked")),
|
|
_P("cell-850", "Cellular 850 MHz", "Telecom",
|
|
824_000_000, 894_000_000, 200_000, "raw", 1_400_000,
|
|
"GSM/LTE band 5", ("telecom",)),
|
|
_P("cell-1900", "PCS 1900 MHz", "Telecom",
|
|
1_850_000_000, 1_990_000_000, 200_000, "raw", 1_400_000,
|
|
"LTE band 2/25 -- above most RTL tuners", ("telecom",)),
|
|
_P("33cm", "33 cm (902-928) Amateur", "Amateur Radio",
|
|
902_000_000, 928_000_000, 25_000, "nfm", 16_000, "", ("ham",)),
|
|
_P("33cm-weak", "33 cm Weak Signal (CW/SSB)", "Amateur Radio",
|
|
902_100_000, 903_100_000, 500, "usb", 2_800,
|
|
"903.100 SSB calling", ("ham", "cw")),
|
|
|
|
# ------------------------------------------------------------------
|
|
# ISM / SRD / consumer
|
|
# ------------------------------------------------------------------
|
|
_P("ism-315", "ISM 315 MHz (SRD)", "ISM / Devices",
|
|
314_900_000, 315_100_000, 10_000, "raw", 50_000,
|
|
"TPMS, key fobs, garage doors, OOK/FSK", ("ism",)),
|
|
_P("ism-390", "ISM 390 MHz (SRD)", "ISM / Devices",
|
|
389_900_000, 390_100_000, 10_000, "raw", 50_000,
|
|
"GM/Ford TPMS and remotes", ("ism",)),
|
|
_P("ism-433", "ISM 433 MHz (SRD)", "ISM / Devices",
|
|
433_050_000, 434_790_000, 10_000, "raw", 50_000,
|
|
"Weather stations, sensors, remotes", ("ism",)),
|
|
_P("ism-915", "ISM 902-928 MHz", "ISM / Devices",
|
|
902_000_000, 928_000_000, 100_000, "raw", 200_000,
|
|
"Smart meters, LoRa, FHSS, Z-Wave 908.4", ("ism",)),
|
|
_P("zwave", "Z-Wave (US)", "ISM / Devices",
|
|
908_000_000, 916_500_000, 100_000, "raw", 100_000,
|
|
"908.4 / 916.0 MHz", ("ism",)),
|
|
_P("tpms", "TPMS Tyre Sensors", "ISM / Devices",
|
|
314_900_000, 315_100_000, 10_000, "raw", 50_000,
|
|
"Also check 433.92 MHz", ("ism",)),
|
|
_P("cordless-phones", "Cordless Phones (legacy)", "Consumer",
|
|
43_000_000, 50_000_000, 20_000, "nfm", 12_500,
|
|
"43-50 MHz analogue handsets", ("consumer",)),
|
|
_P("baby-monitors", "Baby Monitors / Analogue Video", "Consumer",
|
|
49_000_000, 50_000_000, 20_000, "nfm", 25_000, "", ("consumer",)),
|
|
|
|
# ------------------------------------------------------------------
|
|
# Data / paging / aviation surveillance
|
|
# ------------------------------------------------------------------
|
|
_P("pagers-vhf", "VHF Paging (POCSAG/FLEX)", "Paging",
|
|
152_000_000, 159_000_000, 12_500, "nfm", 12_500,
|
|
"152.0-152.24, 157.45, 158.1", ("paging", "data")),
|
|
_P("pagers-uhf", "UHF / 900 MHz Paging", "Paging",
|
|
929_000_000, 932_000_000, 25_000, "nfm", 25_000,
|
|
"FLEX and POCSAG carriers", ("paging", "data")),
|
|
_P("adsb", "ADS-B (1090 MHz)", "Aviation",
|
|
1_089_000_000, 1_091_000_000, 1_000_000, "raw", 2_000_000,
|
|
"Mode S extended squitter, PPM at 1 Mbit/s", ("aviation", "data")),
|
|
_P("uat-978", "UAT / ADS-B 978 MHz", "Aviation",
|
|
977_000_000, 979_000_000, 1_000_000, "raw", 2_000_000,
|
|
"US general aviation ADS-B and FIS-B", ("aviation", "data")),
|
|
_P("dme-tacan", "DME / TACAN", "Aviation",
|
|
960_000_000, 1_215_000_000, 1_000_000, "raw", 1_000_000,
|
|
"Pulse pairs, aircraft navigation", ("aviation",)),
|
|
_P("inmarsat", "Inmarsat L-band Downlink", "Satellite",
|
|
1_525_000_000, 1_559_000_000, 25_000, "raw", 50_000,
|
|
"STD-C / AERO -- needs an LNA and a patch antenna", ("satellite",)),
|
|
_P("iridium", "Iridium Downlink", "Satellite",
|
|
1_616_000_000, 1_626_500_000, 100_000, "raw", 500_000,
|
|
"Bursty QPSK, top of the RTL tuning range", ("satellite",)),
|
|
_P("gps-l1", "GPS L1", "Satellite",
|
|
1_575_000_000, 1_576_000_000, 1_000_000, "raw", 2_000_000,
|
|
"Below the noise floor without correlation", ("satellite",)),
|
|
_P("weather-fax", "HF Weather Fax / RTTY", "Utility / Time",
|
|
3_800_000, 17_200_000, 1_000, "usb", 2_800,
|
|
"NOAA HF FAX schedules", ("utility", "hf")),
|
|
|
|
# ------------------------------------------------------------------
|
|
# Broad sweeps
|
|
# ------------------------------------------------------------------
|
|
_P("everything-vhf", "Full VHF Sweep", "Wide Sweeps",
|
|
30_000_000, 300_000_000, 12_500, "nfm", 12_500,
|
|
"Slow but thorough", ("sweep",)),
|
|
_P("everything-uhf", "Full UHF Sweep", "Wide Sweeps",
|
|
300_000_000, 1_000_000_000, 12_500, "nfm", 12_500,
|
|
"Very slow", ("sweep",)),
|
|
_P("everything", "Full Tuner Range Sweep", "Wide Sweeps",
|
|
24_000_000, 1_700_000_000, 25_000, "nfm", 12_500,
|
|
"The whole R820T range; expect long cycle times", ("sweep",)),
|
|
_P("everything-hf", "Full HF Sweep (direct sampling)", "Wide Sweeps",
|
|
500_000, 28_000_000, 1_000, "usb", 3_000,
|
|
"Requires direct sampling and an HF antenna", ("sweep", "hf")),
|
|
_P("common-scanner", "Common Scanner Bands", "Wide Sweeps",
|
|
144_000_000, 174_000_000, 12_500, "nfm", 12_500,
|
|
"The classic 'action band'", ("sweep",)),
|
|
# Whole amateur bands. Mode "auto" and bandwidth 0 mean the demodulator
|
|
# and filter width are looked up per frequency from the segment presets
|
|
# above, so one range covers a band whose bottom is CW, middle SSB and
|
|
# top FM without any of it being demodulated the wrong way.
|
|
_P("160m-complete", "160 m Complete Band", "Amateur Radio",
|
|
1_800_000, 2_000_000, 500, "auto", 0,
|
|
"CW, digital and LSB phone", ("ham", "hf", "complete")),
|
|
_P("80m-complete", "80/75 m Complete Band", "Amateur Radio",
|
|
3_500_000, 4_000_000, 500, "auto", 0,
|
|
"CW below 3.600, LSB phone above", ("ham", "hf", "complete")),
|
|
_P("60m-complete", "60 m Complete Band", "Amateur Radio",
|
|
5_330_500, 5_405_000, 100, "auto", 0,
|
|
"Five fixed USB channels", ("ham", "hf", "complete")),
|
|
_P("40m-complete", "40 m Complete Band", "Amateur Radio",
|
|
7_000_000, 7_300_000, 500, "auto", 0,
|
|
"CW below 7.125, LSB phone above", ("ham", "hf", "complete")),
|
|
_P("30m-complete", "30 m Complete Band", "Amateur Radio",
|
|
10_100_000, 10_150_000, 200, "auto", 0,
|
|
"CW and digital only, no phone", ("ham", "hf", "complete")),
|
|
_P("20m-complete", "20 m Complete Band", "Amateur Radio",
|
|
14_000_000, 14_350_000, 500, "auto", 0,
|
|
"CW below 14.150, USB phone above", ("ham", "hf", "complete")),
|
|
_P("17m-complete", "17 m Complete Band", "Amateur Radio",
|
|
18_068_000, 18_168_000, 500, "auto", 0,
|
|
"CW below 18.110, USB phone above", ("ham", "hf", "complete")),
|
|
_P("15m-complete", "15 m Complete Band", "Amateur Radio",
|
|
21_000_000, 21_450_000, 500, "auto", 0,
|
|
"CW below 21.200, USB phone above", ("ham", "hf", "complete")),
|
|
_P("12m-complete", "12 m Complete Band", "Amateur Radio",
|
|
24_890_000, 24_990_000, 500, "auto", 0,
|
|
"CW below 24.930, USB phone above", ("ham", "hf", "complete")),
|
|
_P("10m-complete", "10 m Complete Band", "Amateur Radio",
|
|
28_000_000, 29_700_000, 1_000, "auto", 0,
|
|
"CW, USB phone, then FM repeaters at the top", ("ham", "hf", "complete")),
|
|
_P("6m-complete", "6 m Complete Band", "Amateur Radio",
|
|
50_000_000, 54_000_000, 5_000, "auto", 0,
|
|
"CW, SSB, then FM above 50.3", ("ham", "complete")),
|
|
_P("2m-complete", "2 m Complete Band", "Amateur Radio",
|
|
144_000_000, 148_000_000, 5_000, "auto", 0,
|
|
"CW, SSB, then FM above 144.3", ("ham", "complete")),
|
|
_P("1.25m-complete", "1.25 m Complete Band", "Amateur Radio",
|
|
222_000_000, 225_000_000, 5_000, "auto", 0,
|
|
"Weak signal at the bottom, FM above", ("ham", "complete")),
|
|
_P("70cm-complete", "70 cm Complete Band", "Amateur Radio",
|
|
420_000_000, 450_000_000, 6_250, "auto", 0,
|
|
"ATV and weak signal low, FM repeaters above 440",
|
|
("ham", "complete")),
|
|
_P("33cm-complete", "33 cm Complete Band", "Amateur Radio",
|
|
902_000_000, 928_000_000, 12_500, "auto", 0,
|
|
"Shared with ISM devices throughout", ("ham", "complete")),
|
|
|
|
_P("all-cw", "All CW Segments", "Wide Sweeps",
|
|
1_800_000, 432_100_000, 200, "cw", 500,
|
|
"Every CW allocation from 160 m to 70 cm in one sweep; the HF part "
|
|
"needs direct sampling and an HF antenna",
|
|
("sweep", "cw", "ham"),
|
|
members=("160m-cw", "80m-cw", "40m-cw", "30m", "20m-cw", "17m-cw",
|
|
"15m-cw", "12m-cw", "10m-cw", "6m-cw", "2m-cw", "70cm-cw")),
|
|
)
|
|
|
|
|
|
CATEGORIES: tuple[str, ...] = tuple(
|
|
dict.fromkeys(p.category for p in PRESETS)
|
|
)
|
|
|
|
_BY_KEY = {p.key: p for p in PRESETS}
|
|
|
|
|
|
def by_key(key: str) -> BandPreset | None:
|
|
return _BY_KEY.get(key.strip().lower())
|
|
|
|
|
|
def in_category(category: str) -> list[BandPreset]:
|
|
cl = category.strip().lower()
|
|
return [p for p in PRESETS if p.category.lower() == cl]
|
|
|
|
|
|
def search(term: str) -> list[BandPreset]:
|
|
"""Fuzzy-ish preset lookup over key, name, note and tags."""
|
|
t = term.strip().lower()
|
|
if not t:
|
|
return []
|
|
hits = []
|
|
for p in PRESETS:
|
|
hay = " ".join((p.key, p.name, p.note, p.category, " ".join(p.tags))).lower()
|
|
if t in hay:
|
|
hits.append(p)
|
|
return hits
|
|
|
|
|
|
def presets_covering(hz: float) -> list[BandPreset]:
|
|
"""Every preset whose span contains ``hz`` -- used to label detections."""
|
|
return [p for p in PRESETS if not p.is_group and p.start <= hz <= p.stop]
|
|
|
|
|
|
def expand_preset(key: str) -> list[BandPreset]:
|
|
"""The presets a key stands for, following groups to their members."""
|
|
preset = by_key(key)
|
|
return preset.expand() if preset is not None else []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Saying what a frequency is
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# The preset table exists to *tune*: a preset is a span, a step and a
|
|
# demodulator. Naming a frequency is a different job, and the table is not
|
|
# shaped for it -- several presets cover any given frequency, some of them
|
|
# whole-tuner sweeps that say nothing at all. These functions rank the
|
|
# candidates and pick the one an operator would actually name.
|
|
|
|
# Presets that exist to be scanned, not to describe: a hit at 421 MHz is in
|
|
# the 70 cm band, and calling it "Full UHF Sweep" is worse than saying
|
|
# nothing. "complete" presets duplicate a narrower one with the same name.
|
|
LABEL_SKIP_TAGS = frozenset({"sweep", "complete"})
|
|
|
|
# Unlicensed device allocations overlap real ones. 433.92 and 915 MHz are
|
|
# ISM, but they are also 70 cm and 33 cm, and a signal there is far more
|
|
# likely to be worth naming as the amateur band. Demoted, not dropped: where
|
|
# ISM is the only thing covering a frequency it is still the right answer.
|
|
LABEL_DEMOTED_TAGS = frozenset({"ism"})
|
|
|
|
# Where both cover a frequency, the second tag names it. The shortwave
|
|
# broadcast bands are a Region 1 and 3 allocation; this table is documented
|
|
# as Region 2, where 3.9-4.0 and 7.2-7.3 MHz are amateur and nothing else.
|
|
# Conditional rather than a flat demotion, because 6 MHz really is 49 m
|
|
# shortwave and there is no amateur band anywhere near it.
|
|
LABEL_YIELDS: tuple[tuple[str, str], ...] = (("swl", "ham"),)
|
|
|
|
# Two presets whose spans are within this of each other are the same band
|
|
# described twice -- GMRS/FRS and the FRS simplex channels differ by one
|
|
# channel. Prefer whichever the table lists first, which is the broader
|
|
# name.
|
|
_LABEL_TIE = 1.10
|
|
|
|
_INDEX = {p.key: i for i, p in enumerate(PRESETS)}
|
|
|
|
|
|
def _demoted(p: BandPreset, present: frozenset[str]) -> int:
|
|
tags = set(p.tags)
|
|
if tags & LABEL_DEMOTED_TAGS:
|
|
return 1
|
|
return 1 if any(loser in tags and winner in present
|
|
for loser, winner in LABEL_YIELDS) else 0
|
|
|
|
|
|
def _rank(p: BandPreset, present: frozenset[str]) -> tuple[int, float, int]:
|
|
return (_demoted(p, present), p.span, _INDEX.get(p.key, 0))
|
|
|
|
|
|
def label_presets(hz: float) -> list[BandPreset]:
|
|
"""Every preset that names ``hz``, most specific first."""
|
|
hits = [p for p in presets_covering(hz)
|
|
if not (set(p.tags) & LABEL_SKIP_TAGS)]
|
|
present = frozenset(t for p in hits for t in p.tags)
|
|
hits.sort(key=lambda p: _rank(p, present))
|
|
if len(hits) > 1:
|
|
# Among presets that are effectively the same width, the table's own
|
|
# order decides, so the canonical name wins over a sub-segment of it.
|
|
best = hits[0]
|
|
close = [p for p in hits
|
|
if _demoted(p, present) == _demoted(best, present)
|
|
and p.span <= best.span * _LABEL_TIE]
|
|
if len(close) > 1:
|
|
first = min(close, key=lambda p: _INDEX.get(p.key, 0))
|
|
hits.remove(first)
|
|
hits.insert(0, first)
|
|
return hits
|
|
|
|
|
|
_PAREN = re.compile(r"\s*\([^)]*\)")
|
|
|
|
|
|
def shorten_band(name: str, width: int = 0) -> str:
|
|
"""A band name that fits in a column, without becoming a different name.
|
|
|
|
Parentheses are the first thing to go: they hold a restatement of the
|
|
frequency ("ADS-B (1090 MHz)") next to a number the display is already
|
|
showing. Only if that is still too long is the name cut at a separator,
|
|
which keeps "800 MHz Public Safety" rather than truncating mid-word.
|
|
"""
|
|
name = _PAREN.sub("", name)
|
|
name = " ".join(name.split())
|
|
if not width or len(name) <= width:
|
|
return name
|
|
for sep in (" / ", " - ", " — "):
|
|
head = name.split(sep)[0]
|
|
if len(head) <= width:
|
|
return head
|
|
return name[:max(1, width - 1)].rstrip() + "…"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BandLabel:
|
|
"""What a frequency is, in as many words as there is room for."""
|
|
|
|
hz: float
|
|
presets: tuple[BandPreset, ...] = ()
|
|
|
|
def __bool__(self) -> bool:
|
|
return bool(self.presets)
|
|
|
|
@property
|
|
def best(self) -> BandPreset | None:
|
|
return self.presets[0] if self.presets else None
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return self.presets[0].name if self.presets else ""
|
|
|
|
@property
|
|
def key(self) -> str:
|
|
return self.presets[0].key if self.presets else ""
|
|
|
|
@property
|
|
def category(self) -> str:
|
|
return self.presets[0].category if self.presets else ""
|
|
|
|
@property
|
|
def names(self) -> list[str]:
|
|
return [p.name for p in self.presets]
|
|
|
|
def short(self, width: int = 0) -> str:
|
|
return shorten_band(self.name, width) if self.presets else ""
|
|
|
|
def describe(self) -> str:
|
|
"""Name and service, for somewhere with a whole line to spare."""
|
|
if not self.presets:
|
|
return ""
|
|
best = self.presets[0]
|
|
return f"{best.name} ({best.category})" if best.category != best.name \
|
|
else best.name
|
|
|
|
|
|
def label_for(hz: float) -> BandLabel:
|
|
return BandLabel(float(hz), tuple(label_presets(hz)))
|
|
|
|
|
|
def band_label(hz: float, width: int = 0) -> str:
|
|
"""The short name of the band ``hz`` falls in, or "" if none does."""
|
|
return label_for(hz).short(width)
|
|
|
|
|
|
def band_name(hz: float) -> str:
|
|
"""The full name of the band ``hz`` falls in, or "" if none does."""
|
|
return label_for(hz).name
|
|
|
|
|
|
def band_names(hz: float, limit: int = 3) -> list[str]:
|
|
"""Every band naming ``hz``, most specific first."""
|
|
return label_for(hz).names[:limit]
|