Subsystem Search

Substation in your own code

By the end of this chapter, a short program of your own hears each transmission as Substation does, and acts on it.

This chapter is for programmers. Using Substation needs none of it: Substation runs from commands and a settings file, as every chapter before this one has shown, and nothing after this chapter needs it. If you do not write Python, skip to the next chapter.

The folder so far

The radio folder as An antenna for your band left it: Substation installed in an environment inside it, switched on, its defaults kept as defaults.yaml, your band in config.yaml with settings for each receiver you own, and four test signals made.

$ mkdir radio
$ cd radio
$ python3 -m venv .venv
$ source .venv/bin/activate
$ pip install "substation==0.8.2"
…
Successfully installed … substation-…
$ substation --init
Created /home/keanu/radio/config.yaml
This is the full default configuration, fully commented. Edit it to suit
your hardware and bands, then start a scan, e.g. `substation --band amateur_2m`.
Any setting you delete falls back to the built-in default.
$ mv config.yaml defaults.yaml
bands:
  amateur_2m_simplex:
    type: NFM
    reception_class: general
    freq_start: 145.2e+6
    freq_end: 145.5625e+6
    channel_spacing: 12.5e+3
    sample_rate: 2.4e+6
    snr_threshold_db: 4.5
    activation_variance_db: 3
    device_overrides:
      hackrf:
        sample_rate: 2.0e+6
        sdr_gain_db: 36
        snr_threshold_db: 6
      airspy:
        sample_rate: 2.5e+6
        sdr_gain_elements:
          LNA: 10
          MIX: 5
          VGA: 12
        snr_threshold_db: 6
      airspyhf:
        sample_rate: 0.912e+6
        snr_threshold_db: 6
# Makes test-signal.wav: eight seconds of the 2m band as a receiver hands it over,
# hiss with a transmission in the middle, for Substation to scan in place of a receiver.

import wave

import numpy

# The frequency the transmission is sent on, in hertz, and how many seconds it lasts.
frequency = 145_500_000
length = 3

# The frequency the recording is centred on, and how many IQ samples it holds
# a second, as a receiver set for the 2m band hands them over.
centre = 145_000_000
rate = 2_400_000

# Two seconds of hiss first, while Substation measures it, and three after.
start = 2
seconds = start + length + 3

# The hiss is random, but the same every time the script runs.
noise = numpy.random.default_rng(1)

with wave.open("test-signal.wav", "wb") as file:

	# Each IQ sample as two 16-bit numbers, I and Q, held as two audio channels.
	file.setnchannels(2)
	file.setsampwidth(2)
	file.setframerate(rate)

	# A second at a time, so the script needs little memory.
	for second in range(seconds):

		# The moment of each IQ sample, in seconds.
		t = second + numpy.arange(rate) / rate

		# The receiver's own hiss.
		hiss = (noise.standard_normal(rate) + 1j * noise.standard_normal(rate)) * 0.01

		# A buzz like a voice: overtones of a low note, each softer than the one below.
		voice = sum(5.3 / n ** 2 * numpy.sin(2 * numpy.pi * 150 * n * t) for n in range(2, 7))

		# Louder and softer six times a second, like syllables.
		loudness = numpy.abs(numpy.sin(2 * numpy.pi * 3 * t))

		# FM: the sound swings the transmission's frequency to either side of its own.
		phase = 2 * numpy.pi * (frequency - centre) * t + loudness * voice

		# The transmission, only while it is on, added to the hiss.
		on = (t >= start) & (t < start + length)
		iq = hiss + numpy.where(on, 0.3 * numpy.exp(1j * phase), 0)

		# Written as pairs of 16-bit numbers, I then Q.
		pairs = numpy.stack([iq.real, iq.imag], axis=1)
		file.writeframes((pairs * 32767).astype("<i2").tobytes())
$ python make_signal.py
# Makes test-noise.wav: six seconds of the 2m band as a receiver hands it over,
# with three things in it strong enough to cross a threshold: a steady noise,
# a burst too short to be a transmission, and a transmission.

import wave

import numpy

# The frequency of each, in hertz.
steady = 145_300_000
burst = 145_400_000
transmission = 145_500_000

# The frequency the recording is centred on, and how many IQ samples it holds
# a second, as a receiver set for the 2m band hands them over.
centre = 145_000_000
rate = 2_400_000

# Six seconds in all.
seconds = 6

# The hiss and the noise are random, but the same every time the script runs.
noise = numpy.random.default_rng(2)

# How far the steady noise has wandered, carried from one second to the next.
drift = 0.0

with wave.open("test-noise.wav", "wb") as file:

	# Each IQ sample as two 16-bit numbers, I and Q, held as two audio channels.
	file.setnchannels(2)
	file.setsampwidth(2)
	file.setframerate(rate)

	# A second at a time, so the script needs little memory.
	for second in range(seconds):

		# The moment of each IQ sample, in seconds.
		t = second + numpy.arange(rate) / rate

		# The receiver's own hiss.
		hiss = (noise.standard_normal(rate) + 1j * noise.standard_normal(rate)) * 0.01

		# The steady noise: a signal whose frequency wanders at random, carrying nothing.
		wander = drift + numpy.cumsum(noise.standard_normal(rate)) * 0.02
		drift = wander[-1]
		steady_noise = 0.03 * numpy.exp(1j * (2 * numpy.pi * (steady - centre) * t + wander))

		# The test signal's buzz, rising and falling, for the burst and the transmission.
		voice = sum(5.3 / n ** 2 * numpy.sin(2 * numpy.pi * 150 * n * t) for n in range(2, 7))
		loudness = numpy.abs(numpy.sin(2 * numpy.pi * 3 * t))

		# The burst, a twentieth of a second long, three seconds in.
		bursting = (t >= 3) & (t < 3.05)
		brief = numpy.where(bursting, 0.3 * numpy.exp(1j * (2 * numpy.pi * (burst - centre) * t + loudness * voice)), 0)

		# The transmission, from two seconds in to four.
		talking = (t >= 2) & (t < 4)
		sent = numpy.where(talking, 0.3 * numpy.exp(1j * (2 * numpy.pi * (transmission - centre) * t + loudness * voice)), 0)

		# All of it together, written as pairs of 16-bit numbers, I then Q.
		iq = hiss + steady_noise + brief + sent
		pairs = numpy.stack([iq.real, iq.imag], axis=1)
		file.writeframes((pairs * 32767).astype("<i2").tobytes())
$ python make_noise.py
# Makes test-loud.wav: eight seconds of the 2m band as a receiver hands it over,
# with a transmission so strong it overloads the receiver, as one sent close by
# would with the gain turned up too far.

import wave

import numpy

# The frequency the transmission is sent on, in hertz, and how many seconds it lasts.
frequency = 145_500_000
length = 3

# The frequency the recording is centred on, and how many IQ samples it holds
# a second, as a receiver set for the 2m band hands them over.
centre = 145_000_000
rate = 2_400_000

# Three seconds of hiss first, while Substation measures it, and two after.
start = 3
seconds = start + length + 2

# The hiss is random, but the same every time the script runs.
noise = numpy.random.default_rng(1)

with wave.open("test-loud.wav", "wb") as file:

	# Each IQ sample as two 16-bit numbers, I and Q, held as two audio channels.
	file.setnchannels(2)
	file.setsampwidth(2)
	file.setframerate(rate)

	# A second at a time, so the script needs little memory.
	for second in range(seconds):

		# The moment of each IQ sample, in seconds.
		t = second + numpy.arange(rate) / rate

		# The receiver's own hiss.
		hiss = (noise.standard_normal(rate) + 1j * noise.standard_normal(rate)) * 0.01

		# A buzz like a voice, louder and softer like syllables, as before.
		voice = sum(5.3 / n ** 2 * numpy.sin(2 * numpy.pi * 150 * n * t) for n in range(2, 7))
		loudness = numpy.abs(numpy.sin(2 * numpy.pi * 3 * t))
		phase = 2 * numpy.pi * (frequency - centre) * t + loudness * voice

		# The transmission, ten times as strong as the one in test-signal.wav.
		on = (t >= start) & (t < start + length)
		iq = hiss + numpy.where(on, 3 * numpy.exp(1j * phase), 0)

		# A receiver can hand over nothing stronger than its largest number,
		# so everything stronger is cut off there, as an overloaded one does.
		pairs = numpy.clip(numpy.stack([iq.real, iq.imag], axis=1), -1, 1)
		file.writeframes((pairs * 32767).astype("<i2").tobytes())
$ python make_loud.py
# Makes test-tone.wav: nine seconds of the 2m band as a receiver hands it over,
# with a transmission like the one in test-signal.wav, carrying a low tone under
# its voice, as many radios send.

import wave

import numpy

# The frequency the transmission is sent on, in hertz, and how many seconds it lasts.
frequency = 145_500_000
length = 3

# The tone under the voice, in hertz.
tone = 88.5

# The frequency the recording is centred on, and how many IQ samples it holds
# a second, as a receiver set for the 2m band hands them over.
centre = 145_000_000
rate = 2_400_000

# Three and a half seconds of hiss first, so that Substation has measured it
# before the transmission starts, and nine seconds in all.
start = 3.5
seconds = 9

# The hiss is random, but the same every time the script runs.
noise = numpy.random.default_rng(3)

with wave.open("test-tone.wav", "wb") as file:

	# Each IQ sample as two 16-bit numbers, I and Q, held as two audio channels.
	file.setnchannels(2)
	file.setsampwidth(2)
	file.setframerate(rate)

	# A second at a time, so the script needs little memory.
	for second in range(seconds):

		# The moment of each IQ sample, in seconds.
		t = second + numpy.arange(rate) / rate

		# The receiver's own hiss.
		hiss = (noise.standard_normal(rate) + 1j * noise.standard_normal(rate)) * 0.01

		# A buzz like a voice: overtones of a low note, each softer than the one below.
		voice = sum(5.3 / n ** 2 * numpy.sin(2 * numpy.pi * 150 * n * t) for n in range(2, 7))

		# Louder and softer six times a second, like syllables.
		loudness = numpy.abs(numpy.sin(2 * numpy.pi * 3 * t))

		# The tone, as steady as the voice is not.
		under = 4 * numpy.sin(2 * numpy.pi * tone * t)

		# FM: the sound swings the transmission's frequency to either side of its own.
		phase = 2 * numpy.pi * (frequency - centre) * t + loudness * voice + under

		# The transmission, only while it is on, added to the hiss.
		on = (t >= start) & (t < start + length)
		iq = hiss + numpy.where(on, 0.3 * numpy.exp(1j * phase), 0)

		# Written as pairs of 16-bit numbers, I then Q.
		pairs = numpy.stack([iq.real, iq.imag], axis=1)
		file.writeframes((pairs * 32767).astype("<i2").tobytes())
$ python make_tone.py

A program that listens

Substation can run inside a Python program of your own, and call your code each time a radio channel turns on or off, or a recording is saved: to send a notification, write to a database, or start something else. This program prints a line for each. Save it as listen.py in the radio folder:

# Scans test-tone.wav as Substation does, and says what it hears: each time a
# radio channel turns on or off, and each recording it saves.

import asyncio
import datetime
import pathlib

import substation.config
import substation.scanner


def heard (band: str, channel: int, on: bool, snr: float) -> None:

	"""Say that a radio channel turned on or off, and how far above the hiss it was."""

	print(f"{band}: radio channel {channel} {'on' if on else 'off'} at {snr:.1f} dB")


def saved (band: str, channel: int, path: str) -> None:

	"""Say which recording was saved."""

	print(f"{band}: radio channel {channel} saved {pathlib.Path(path).name}")


async def main () -> None:

	"""Scan your band in the test signal, with the settings in config.yaml."""

	scanner = substation.scanner.RadioScanner(
		config=substation.config.load_config(),
		band_name="amateur_2m_simplex",
		device_type="file",
		device_kwargs={"file_path": "test-tone.wav", "center_freq": 145_000_000},
		clock=substation.scanner.VirtualClock(datetime.datetime(2026, 10, 5, 23, 30), 2_400_000),
	)

	scanner.add_state_callback(heard)
	scanner.add_recording_callback(saved)

	await scanner.scan()


asyncio.run(main())

Run it with the environment switched on, as Substation's own command is:

$ python listen.py
amateur_2m_simplex: radio channel 25 on at 20.5 dB
amateur_2m_simplex: radio channel 25 off at 0.0 dB
amateur_2m_simplex: radio channel 25 saved 2026-10-05_23-30-03_amateur_2m_simplex_25_145.5MHz_20.5dB_file_0.wav

It heard what the substation command heard in What a recording holds: radio channel 25 turning on and off, and a recording, saved in the same folder, named for half past eleven, the time the program gives the scan.

How it works

With a receiver

For a receiver, name its kind in place of the file, and leave out the clock, so that recordings take the computer's own time:

Example not checked: it is not run when this site is built.

scanner = substation.scanner.RadioScanner(
	config=substation.config.load_config(),
	band_name="amateur_2m_simplex",
	device_type="rtlsdr",
)

The scan then runs until you stop it. Ctrl+C cancels it, and once Substation has closed its recordings and the receiver, asyncio.run() raises KeyboardInterrupt, which your program can catch to end quietly.

More from each event

The two callbacks are the simple form of two of the scanner's six events. on() subscribes a function to any of them, which is then called with the event's values by name: channel_state with the radio channel's frequency and any tone heard as well, recording_started, recording_saved, recording_discarded, noise_floor, and channel_snr, with every radio channel's SNR at each slice it measures. The reference says what each carries, when it comes, and which thread calls your function.

Substation logs what it does through Python's logging, which shows only its warnings until your program sets logging up. logging.basicConfig(level=logging.INFO), before the scan starts, shows the lines the substation command logs.

A scanner follows the osc settings in your configuration, as the command does, and OscEventSender sends its events over OSC somewhere else as well. The Python reference documents every name Substation offers a program.

Substation's own examples are whole programs to read: examples/scan_demo.py scans PMR446 with an RTL-SDR, printing each radio channel turning on or off and each recording saved, and examples/scan_osc.py attaches an OscEventSender to the scanner. Leave the osc settings switched off when you run the second, or each message is sent twice.

The next chapter connects Substation to Subsample and Subsequence, with settings alone.