substation 0.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- substation/__init__.py +46 -0
- substation/__main__.py +15 -0
- substation/antenna.py +371 -0
- substation/cli.py +408 -0
- substation/config.py +913 -0
- substation/config.yaml.default +635 -0
- substation/constants.py +225 -0
- substation/devices/__init__.py +113 -0
- substation/devices/base.py +168 -0
- substation/devices/file.py +272 -0
- substation/devices/hackrf.py +374 -0
- substation/devices/rtlsdr.py +167 -0
- substation/devices/soapysdr.py +758 -0
- substation/dsp/__init__.py +3 -0
- substation/dsp/demodulation.py +853 -0
- substation/dsp/filters.py +348 -0
- substation/dsp/noise_reduction.py +485 -0
- substation/osc_sender.py +220 -0
- substation/recording.py +1011 -0
- substation/scanner.py +2033 -0
- substation-0.5.1.dist-info/METADATA +913 -0
- substation-0.5.1.dist-info/RECORD +28 -0
- substation-0.5.1.dist-info/WHEEL +5 -0
- substation-0.5.1.dist-info/entry_points.txt +3 -0
- substation-0.5.1.dist-info/licenses/LICENSE +661 -0
- substation-0.5.1.dist-info/scm_file_list.json +51 -0
- substation-0.5.1.dist-info/scm_version.json +8 -0
- substation-0.5.1.dist-info/top_level.txt +1 -0
substation/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Substation - Software-defined radio band scanner.
|
|
3
|
+
|
|
4
|
+
A Python application for scanning and recording activity on radio bands.
|
|
5
|
+
|
|
6
|
+
Supported hardware:
|
|
7
|
+
- RTL-SDR (native driver)
|
|
8
|
+
- HackRF One (native driver)
|
|
9
|
+
- AirSpy R2, AirSpy HF+ Discovery, and any other SoapySDR-supported
|
|
10
|
+
device (via the SoapySDR wrapper)
|
|
11
|
+
|
|
12
|
+
Features:
|
|
13
|
+
- Automatic channel detection using SNR (Signal-to-Noise Ratio) with
|
|
14
|
+
per-band configurable hysteresis and three-layer noise rejection
|
|
15
|
+
(RF variance, audio spectral flatness, post-recording flatness
|
|
16
|
+
check) to eliminate false recordings
|
|
17
|
+
- Audio silence timeout to stop recording when an AM carrier persists
|
|
18
|
+
after voice ends
|
|
19
|
+
- Demodulation of NFM, AM, and SSB (USB/LSB via Weaver's method) with
|
|
20
|
+
streaming polyphase FIR resampler for artifact-free block processing
|
|
21
|
+
- CTCSS (51 standard tones) and DCS (23-bit Golay-coded) subaudible tone
|
|
22
|
+
detection on NFM, embedded in recording metadata
|
|
23
|
+
- Automatic per-channel recording in WAV (Broadcast WAV with embedded
|
|
24
|
+
frequency / timestamp / modulation / tone metadata) or FLAC (lossless,
|
|
25
|
+
Vorbis comments) with spectral-subtraction noise reduction and optional
|
|
26
|
+
experimental dynamics-curve expander
|
|
27
|
+
- PPM frequency calibration against a known reference signal
|
|
28
|
+
- Unified event emitter (on / off / emit) — six events covering channel
|
|
29
|
+
state, recording lifecycle, noise floor, and per-slice SNR snapshots;
|
|
30
|
+
the channel_state event carries the detected CTCSS tone or DCS code
|
|
31
|
+
as a property of each activation; used by the OSC bridge and the
|
|
32
|
+
Supervisor dashboard integration
|
|
33
|
+
- Optional OSC event forwarding to downstream tools (MIDI sequencer,
|
|
34
|
+
sampler, VJ software, ...) via substation.osc_sender — install the
|
|
35
|
+
optional extra with pip install "substation[osc]"
|
|
36
|
+
- Optional real-time Supervisor dashboard — broadcasts scanner state
|
|
37
|
+
over WebSocket for a web UI — install the separate supervisor package
|
|
38
|
+
from GitHub (see INSTALL.md)
|
|
39
|
+
|
|
40
|
+
Typical usage:
|
|
41
|
+
substation --init # Write a starter config.yaml
|
|
42
|
+
substation --band pmr
|
|
43
|
+
substation --list-bands
|
|
44
|
+
substation --band air_civil_1 --device-type hackrf
|
|
45
|
+
substation --band air_civil_bristol --device-type airspyhf
|
|
46
|
+
"""
|
substation/__main__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Entry point for running Substation as a module.
|
|
3
|
+
|
|
4
|
+
This file allows the package to be executed as:
|
|
5
|
+
python -m substation [args]
|
|
6
|
+
|
|
7
|
+
It simply delegates to the CLI main() function which handles
|
|
8
|
+
all argument parsing and program execution.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import substation.cli
|
|
13
|
+
|
|
14
|
+
if __name__ == '__main__':
|
|
15
|
+
sys.exit(substation.cli.main())
|
substation/antenna.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Antenna length calculator for Substation bands.
|
|
3
|
+
|
|
4
|
+
Calculates and prints the optimal length of common antennae (half-wave
|
|
5
|
+
dipole, quarter-wave vertical, 5/8-wave vertical, full-wave loop) for
|
|
6
|
+
either a configured Substation band or a manually-specified frequency.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
substation-antenna --band hf_night_4mhz
|
|
10
|
+
substation-antenna --freq 4625e3
|
|
11
|
+
substation-antenna --list
|
|
12
|
+
|
|
13
|
+
Run with --help for the full argument list.
|
|
14
|
+
|
|
15
|
+
The math is the standard amateur-radio antenna formula:
|
|
16
|
+
|
|
17
|
+
wavelength = c / frequency
|
|
18
|
+
half-wave dipole = velocity_factor * wavelength / 2
|
|
19
|
+
quarter wave = velocity_factor * wavelength / 4
|
|
20
|
+
5/8 wave = 0.625 * wavelength
|
|
21
|
+
full-wave loop = loop_velocity_factor * wavelength
|
|
22
|
+
|
|
23
|
+
The "velocity factor" is a small correction (~0.95) that accounts for
|
|
24
|
+
end-effect capacitance and the wave slowing down slightly in real wire
|
|
25
|
+
versus theoretical free-space propagation. Loop antennas use a
|
|
26
|
+
slightly different correction (~0.97). These values are universal
|
|
27
|
+
amateur-radio constants and are hard-coded — almost no user knows what
|
|
28
|
+
to put there, and the small variations between different wire gauges
|
|
29
|
+
are dwarfed by the practical tuning that follows construction.
|
|
30
|
+
|
|
31
|
+
Note that a configured Substation band is a frequency *range*, not a
|
|
32
|
+
single frequency. At UHF, where the bands span well under 1% of the
|
|
33
|
+
centre frequency, one antenna covers the whole band easily. On HF the
|
|
34
|
+
bands often span 10% or more, and no single antenna length is optimal
|
|
35
|
+
for the entire range. When the band span exceeds BAND_SPREAD_WARN_FRACTION
|
|
36
|
+
the report appends a caveat showing the antenna's natural ~4% SWR
|
|
37
|
+
window and the dipole lengths at the band edges, so the user can
|
|
38
|
+
decide whether to cut for the centre, an edge, or use a tuner.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import argparse
|
|
42
|
+
import pathlib
|
|
43
|
+
import sys
|
|
44
|
+
|
|
45
|
+
import substation.config
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Speed of light in metres per second. The standard physical value;
|
|
49
|
+
# used directly in the wavelength calculation.
|
|
50
|
+
SPEED_OF_LIGHT_M_S = 299_792_458.0
|
|
51
|
+
|
|
52
|
+
# Velocity factor / wire correction for thin-wire dipoles and verticals.
|
|
53
|
+
# Accounts for end-effect capacitance and the small reduction in
|
|
54
|
+
# propagation velocity in real wire versus free space. 0.95 is the
|
|
55
|
+
# universal amateur-radio formula.
|
|
56
|
+
WIRE_VELOCITY_FACTOR = 0.95
|
|
57
|
+
|
|
58
|
+
# Velocity factor for full-wave loops. Loops behave slightly
|
|
59
|
+
# differently from straight-wire antennas; 0.97 is the conventional
|
|
60
|
+
# value used in amateur-radio loop calculators.
|
|
61
|
+
LOOP_VELOCITY_FACTOR = 0.97
|
|
62
|
+
|
|
63
|
+
# A dipole's natural ±2% SWR window is adequate for transmit/receive
|
|
64
|
+
# without a tuner. When a Substation band spans more than 4% of its
|
|
65
|
+
# centre frequency (i.e., the band is wider than the dipole's natural
|
|
66
|
+
# usable bandwidth), the report adds a caveat showing the dipole window
|
|
67
|
+
# and the band-edge antenna lengths.
|
|
68
|
+
BAND_SPREAD_WARN_FRACTION = 0.04
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def compute_antenna_lengths (frequency_hz: float) -> dict:
|
|
72
|
+
|
|
73
|
+
"""
|
|
74
|
+
Compute optimal antenna lengths for a single frequency.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
frequency_hz: Target frequency in hertz. Must be positive.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
A dict containing the wavelength and the lengths of four common
|
|
81
|
+
antenna types, all in metres:
|
|
82
|
+
|
|
83
|
+
wavelength — free-space wavelength c / f
|
|
84
|
+
dipole_total — tip-to-tip half-wave dipole
|
|
85
|
+
dipole_leg — one leg of the same dipole (half of dipole_total)
|
|
86
|
+
quarter_wave_vertical — λ/4 vertical / monopole
|
|
87
|
+
five_eighths_vertical — 5/8 λ vertical (no wire correction)
|
|
88
|
+
full_wave_loop — full-wave loop perimeter
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValueError: If frequency_hz is zero or negative.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
if frequency_hz <= 0:
|
|
95
|
+
raise ValueError(f"frequency_hz must be positive, got {frequency_hz}")
|
|
96
|
+
|
|
97
|
+
wavelength = SPEED_OF_LIGHT_M_S / frequency_hz
|
|
98
|
+
|
|
99
|
+
dipole_total = WIRE_VELOCITY_FACTOR * wavelength / 2.0
|
|
100
|
+
dipole_leg = dipole_total / 2.0
|
|
101
|
+
quarter_wave_vertical = WIRE_VELOCITY_FACTOR * wavelength / 4.0
|
|
102
|
+
five_eighths_vertical = 0.625 * wavelength
|
|
103
|
+
full_wave_loop = LOOP_VELOCITY_FACTOR * wavelength
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
'wavelength': wavelength,
|
|
107
|
+
'dipole_total': dipole_total,
|
|
108
|
+
'dipole_leg': dipole_leg,
|
|
109
|
+
'quarter_wave_vertical': quarter_wave_vertical,
|
|
110
|
+
'five_eighths_vertical': five_eighths_vertical,
|
|
111
|
+
'full_wave_loop': full_wave_loop,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _format_length (metres: float) -> str:
|
|
116
|
+
|
|
117
|
+
"""
|
|
118
|
+
Format a length in metres as either metres (>= 1 m, two decimal
|
|
119
|
+
places) or centimetres (< 1 m, one decimal place).
|
|
120
|
+
|
|
121
|
+
Used so VHF/UHF antennas read as "16.0 cm" instead of "0.16 m" while
|
|
122
|
+
HF antennas read naturally as "30.79 m".
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
if metres >= 1.0:
|
|
126
|
+
return f"{metres:.2f} m"
|
|
127
|
+
|
|
128
|
+
return f"{metres * 100.0:.1f} cm"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def format_antenna_report (
|
|
132
|
+
frequency_hz: float,
|
|
133
|
+
band_name: str | None = None,
|
|
134
|
+
freq_start_hz: float | None = None,
|
|
135
|
+
freq_end_hz: float | None = None,
|
|
136
|
+
) -> str:
|
|
137
|
+
|
|
138
|
+
"""
|
|
139
|
+
Format a human-readable antenna report.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
frequency_hz: Centre frequency to compute against, in hertz.
|
|
143
|
+
band_name: Optional band name; if supplied (along with start
|
|
144
|
+
and end frequencies) the report shows a band header and may
|
|
145
|
+
append a band-spread warning footer.
|
|
146
|
+
freq_start_hz: Lower edge of the band, in hertz. Required when
|
|
147
|
+
band_name is set.
|
|
148
|
+
freq_end_hz: Upper edge of the band, in hertz. Required when
|
|
149
|
+
band_name is set.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
The multi-line report as a string.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
lengths = compute_antenna_lengths(frequency_hz)
|
|
156
|
+
|
|
157
|
+
lines = []
|
|
158
|
+
lines.append("Antenna calculator — Substation")
|
|
159
|
+
|
|
160
|
+
# Header — different shape for band vs single frequency
|
|
161
|
+
if band_name is not None and freq_start_hz is not None and freq_end_hz is not None:
|
|
162
|
+
span_hz = freq_end_hz - freq_start_hz
|
|
163
|
+
span_pct = (span_hz / frequency_hz) * 100.0
|
|
164
|
+
|
|
165
|
+
# Choose a sensible unit for the span: kHz for narrow HF bands,
|
|
166
|
+
# MHz for wider bands and VHF/UHF.
|
|
167
|
+
if span_hz < 1e6:
|
|
168
|
+
span_str = f"{span_hz / 1e3:.0f} kHz"
|
|
169
|
+
else:
|
|
170
|
+
span_str = f"{span_hz / 1e6:.3f} MHz"
|
|
171
|
+
|
|
172
|
+
lines.append(f"Band: {band_name}")
|
|
173
|
+
lines.append(
|
|
174
|
+
f"Frequency range: {freq_start_hz / 1e6:.3f} - {freq_end_hz / 1e6:.3f} MHz "
|
|
175
|
+
f"(centre {frequency_hz / 1e6:.3f} MHz, span {span_str} / {span_pct:.2f}%)"
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
lines.append(f"Frequency: {frequency_hz / 1e6:.3f} MHz")
|
|
179
|
+
|
|
180
|
+
# Wavelength: prefix with "at centre" for band reports so users know
|
|
181
|
+
# the value depends on the band's centre rather than its edges; show
|
|
182
|
+
# centimetres alongside metres for sub-metre VHF/UHF wavelengths.
|
|
183
|
+
wl = lengths['wavelength']
|
|
184
|
+
wl_label = "Wavelength at centre" if band_name else "Wavelength"
|
|
185
|
+
if wl < 1.0:
|
|
186
|
+
lines.append(f"{wl_label}: {wl:.2f} m / {wl * 100.0:.1f} cm")
|
|
187
|
+
else:
|
|
188
|
+
lines.append(f"{wl_label}: {wl:.2f} m")
|
|
189
|
+
|
|
190
|
+
lines.append("")
|
|
191
|
+
|
|
192
|
+
# Antenna table
|
|
193
|
+
heading = "Antenna lengths (calculated for band centre):" if band_name else "Antenna lengths:"
|
|
194
|
+
lines.append(heading)
|
|
195
|
+
lines.append("")
|
|
196
|
+
|
|
197
|
+
lines.append(
|
|
198
|
+
f" Half-wave dipole total {_format_length(lengths['dipole_total'])}"
|
|
199
|
+
f" each leg {_format_length(lengths['dipole_leg'])}"
|
|
200
|
+
)
|
|
201
|
+
lines.append(f" Quarter-wave vertical {_format_length(lengths['quarter_wave_vertical'])}")
|
|
202
|
+
lines.append(f" 5/8-wave vertical {_format_length(lengths['five_eighths_vertical'])}")
|
|
203
|
+
lines.append(f" Full-wave loop {_format_length(lengths['full_wave_loop'])} (perimeter)")
|
|
204
|
+
|
|
205
|
+
# Practical guidance based on frequency range and antenna size.
|
|
206
|
+
# For receive-only SDR use, resonance and SWR are far less critical
|
|
207
|
+
# than for transmit — a "wrong" antenna still picks up signal.
|
|
208
|
+
lines.append("")
|
|
209
|
+
|
|
210
|
+
qw = lengths['quarter_wave_vertical']
|
|
211
|
+
if qw > 2.0:
|
|
212
|
+
# HF / CB — full-size antennas are impractically large for most users
|
|
213
|
+
lines.append("Practical notes (receive only):")
|
|
214
|
+
lines.append("")
|
|
215
|
+
lines.append(f" A full-size antenna at this frequency is large ({_format_length(qw)} quarter-wave).")
|
|
216
|
+
lines.append(" For receive-only SDR scanning, you do NOT need a resonant antenna.")
|
|
217
|
+
lines.append(" Good alternatives:")
|
|
218
|
+
lines.append("")
|
|
219
|
+
lines.append(" - Random wire: any length of wire, ideally outdoors and as")
|
|
220
|
+
lines.append(f" long as you can manage (>{_format_length(qw)} helps). Feed via a 9:1 balun.")
|
|
221
|
+
lines.append(" - Active magnetic loop: compact (< 1m diameter), good for")
|
|
222
|
+
lines.append(" indoor use, rejects local noise. Needs a preamp (built in")
|
|
223
|
+
lines.append(" on commercial units like the MLA-30+).")
|
|
224
|
+
lines.append(" - Shortened loaded vertical: mobile CB/HF whips (1-2m) with")
|
|
225
|
+
lines.append(" loading coils. Less efficient but very practical.")
|
|
226
|
+
lines.append(" - Telescopic whip: basic but works for strong local signals.")
|
|
227
|
+
elif qw > 0.3:
|
|
228
|
+
# VHF — moderate size, full-size antennas are practical
|
|
229
|
+
lines.append("Practical notes (receive only):")
|
|
230
|
+
lines.append("")
|
|
231
|
+
lines.append(f" Full-size antennas at this frequency are practical ({_format_length(qw)} quarter-wave).")
|
|
232
|
+
lines.append(" Good options:")
|
|
233
|
+
lines.append("")
|
|
234
|
+
lines.append(" - Quarter-wave ground plane: simple to build from wire or a")
|
|
235
|
+
lines.append(" telescopic whip cut to length, with 3-4 radials.")
|
|
236
|
+
lines.append(" - Discone: wideband, covers multiple VHF/UHF bands at once.")
|
|
237
|
+
lines.append(" - Collinear vertical: higher gain if you want to focus on")
|
|
238
|
+
lines.append(" one band (e.g. airband, marine, 2m amateur).")
|
|
239
|
+
else:
|
|
240
|
+
# UHF — antennas are small, anything works
|
|
241
|
+
lines.append("Practical notes (receive only):")
|
|
242
|
+
lines.append("")
|
|
243
|
+
lines.append(f" Antennas at this frequency are small ({_format_length(qw)} quarter-wave).")
|
|
244
|
+
lines.append(" Almost any antenna works well:")
|
|
245
|
+
lines.append("")
|
|
246
|
+
lines.append(" - The whip antenna included with most SDR dongles is adequate.")
|
|
247
|
+
lines.append(" - A quarter-wave ground plane takes minutes to build from wire.")
|
|
248
|
+
lines.append(" - Discone: covers the full UHF range and beyond.")
|
|
249
|
+
|
|
250
|
+
# Band-spread warning footer (only for band reports with span > threshold)
|
|
251
|
+
if band_name is not None and freq_start_hz is not None and freq_end_hz is not None:
|
|
252
|
+
span_fraction = (freq_end_hz - freq_start_hz) / frequency_hz
|
|
253
|
+
|
|
254
|
+
if span_fraction > BAND_SPREAD_WARN_FRACTION:
|
|
255
|
+
# Dipole's natural ~±2% usable window
|
|
256
|
+
window_low_hz = frequency_hz * 0.98
|
|
257
|
+
window_high_hz = frequency_hz * 1.02
|
|
258
|
+
|
|
259
|
+
# Dipole lengths at the actual band edges
|
|
260
|
+
edge_low = compute_antenna_lengths(freq_start_hz)
|
|
261
|
+
edge_high = compute_antenna_lengths(freq_end_hz)
|
|
262
|
+
|
|
263
|
+
lines.append("")
|
|
264
|
+
lines.append(
|
|
265
|
+
f"NOTE: This band spans {span_fraction * 100.0:.1f}% of its centre frequency. A single dipole"
|
|
266
|
+
)
|
|
267
|
+
lines.append("optimised for the centre will work acceptably across roughly:")
|
|
268
|
+
lines.append(
|
|
269
|
+
f" centre ± ~2% → {window_low_hz / 1e6:.3f} - {window_high_hz / 1e6:.3f} MHz"
|
|
270
|
+
f" (~4% useful bandwidth)"
|
|
271
|
+
)
|
|
272
|
+
lines.append("The full band is wider than this — if you want both the band edges and")
|
|
273
|
+
lines.append("the middle, consider:")
|
|
274
|
+
lines.append(" - cutting the dipole for the lower edge (longer = covers low freqs better)")
|
|
275
|
+
lines.append(" - using a tuner / matching network")
|
|
276
|
+
lines.append(" - using a wideband antenna (random wire, magnetic loop, discone)")
|
|
277
|
+
lines.append("")
|
|
278
|
+
lines.append("Lengths at the band edges for comparison:")
|
|
279
|
+
lines.append(
|
|
280
|
+
f" {freq_start_hz / 1e6:.3f} MHz: dipole {_format_length(edge_low['dipole_total'])} total"
|
|
281
|
+
)
|
|
282
|
+
lines.append(
|
|
283
|
+
f" {freq_end_hz / 1e6:.3f} MHz: dipole {_format_length(edge_high['dipole_total'])} total"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
return "\n".join(lines)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def main () -> int:
|
|
290
|
+
|
|
291
|
+
"""
|
|
292
|
+
Command-line entry point.
|
|
293
|
+
|
|
294
|
+
Parses arguments, optionally loads the Substation config, and
|
|
295
|
+
prints either an antenna report or a list of bands. Returns the
|
|
296
|
+
process exit code.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
parser = argparse.ArgumentParser(
|
|
300
|
+
prog='substation-antenna',
|
|
301
|
+
description='Calculate optimal antenna lengths for a Substation band or frequency.',
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
group = parser.add_mutually_exclusive_group(required=True)
|
|
305
|
+
group.add_argument('--band', metavar='NAME', help='Configured band name from config.yaml')
|
|
306
|
+
group.add_argument('--freq', type=float, help='Frequency in Hz (e.g. 4625e3 or 446.0e6)')
|
|
307
|
+
group.add_argument('--list', action='store_true', help='List all configured bands')
|
|
308
|
+
|
|
309
|
+
parser.add_argument(
|
|
310
|
+
'--config',
|
|
311
|
+
type=pathlib.Path,
|
|
312
|
+
default=None,
|
|
313
|
+
help='Path to a user config override file (default: config.yaml in CWD if it exists, otherwise the bundled defaults)',
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
args = parser.parse_args()
|
|
317
|
+
|
|
318
|
+
# --list mode. load_config falls back to the bundled defaults when no
|
|
319
|
+
# user config.yaml exists — same behaviour as the main substation CLI —
|
|
320
|
+
# so the FileNotFoundError branch only fires for an explicit --config
|
|
321
|
+
# path that doesn't exist.
|
|
322
|
+
if args.list:
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
cfg = substation.config.load_config(args.config)
|
|
326
|
+
except FileNotFoundError:
|
|
327
|
+
print(f"error: config file not found: {args.config}", file=sys.stderr)
|
|
328
|
+
return 2
|
|
329
|
+
|
|
330
|
+
for name in sorted(cfg.bands):
|
|
331
|
+
print(name)
|
|
332
|
+
|
|
333
|
+
return 0
|
|
334
|
+
|
|
335
|
+
# --freq mode (no config needed)
|
|
336
|
+
if args.freq is not None:
|
|
337
|
+
|
|
338
|
+
if args.freq <= 0:
|
|
339
|
+
print("error: --freq must be positive", file=sys.stderr)
|
|
340
|
+
return 2
|
|
341
|
+
|
|
342
|
+
print(format_antenna_report(args.freq))
|
|
343
|
+
return 0
|
|
344
|
+
|
|
345
|
+
# --band mode (config required)
|
|
346
|
+
try:
|
|
347
|
+
cfg = substation.config.load_config(args.config)
|
|
348
|
+
except FileNotFoundError:
|
|
349
|
+
print(f"error: config file not found: {args.config}", file=sys.stderr)
|
|
350
|
+
return 2
|
|
351
|
+
|
|
352
|
+
if args.band not in cfg.bands:
|
|
353
|
+
print(f"error: band {args.band!r} not found in {args.config}", file=sys.stderr)
|
|
354
|
+
print("available bands: " + ", ".join(sorted(cfg.bands)), file=sys.stderr)
|
|
355
|
+
return 2
|
|
356
|
+
|
|
357
|
+
band = cfg.bands[args.band]
|
|
358
|
+
centre = (band.freq_start + band.freq_end) / 2.0
|
|
359
|
+
|
|
360
|
+
print(format_antenna_report(
|
|
361
|
+
centre,
|
|
362
|
+
band_name=args.band,
|
|
363
|
+
freq_start_hz=band.freq_start,
|
|
364
|
+
freq_end_hz=band.freq_end,
|
|
365
|
+
))
|
|
366
|
+
|
|
367
|
+
return 0
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
if __name__ == '__main__':
|
|
371
|
+
sys.exit(main())
|