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 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())