substation.scanner
The API reference for RadioScanner.
RadioScanner
class RadioScanner(
config_path: str | pathlib.Path | None = None,
band_name: str = 'pmr',
device_type: str = 'rtlsdr',
device_index: int = 0,
config: typing.Any | None = None,
clock: VirtualClock | None = None,
device_kwargs: dict | None = None,
)
Scan one band with one receiver: detect each transmission on its radio channels, and record it where the band records.
Build one from a loaded configuration, register a handler for each event
you want with on(), or with the simpler add_state_callback() and
add_recording_callback(), then await scan():
scanner = substation.scanner.RadioScanner(
config=substation.config.load_config(),
band_name="amateur_2m",
device_type="rtlsdr",
)
scanner.on("channel_state", handler)
await scanner.scan()
The band, the receiver and every setting come from the configuration. The scanner's own attributes are its working state while it scans, not settings to change.
When the configuration's osc settings turn sending on, the scanner also
sends its events as OSC messages, as OscEventSender describes, with no
handler to register. A script that attaches its own OscEventSender to
the same destination as well sends each message twice.
Set up a scanner for one band and one receiver. Nothing is opened until
scan() runs.
Parameters
config_path: A user configuration file, merged over the shipped defaults asload_config()merges it. Used only whenconfigis None.band_name: The band to scan, by its name in the configuration.device_type: The receiver, as--device-typetakes it:rtlsdr,hackrf,airspy,airspyhf,soapy:<driver>, orfilefor IQ file playback.device_index: Which receiver of that type to use, counting from 0.config: A configuration fromload_config(), or a dict to validate, used in place ofconfig_path. A dict does not have the shipped defaults merged under it, so the settings it leaves out take their schema defaults.clock: AVirtualClockfor IQ file playback, which gives recordings the file's time rather than the system clock's.device_kwargs: Extra keyword arguments for the receiver, such asfile_pathandcenter_freqforfile.
Raises
KeyError: The band is not in the configuration.ValueError: The band is wider than itssample_ratecan capture.OSError: A host named in the configuration'soscsettings cannot be found.
Members: add_recording_callback, add_state_callback, emit, off, on, scan
RadioScanner.on
RadioScanner.on(event: str, handler: typing.Callable) -> None
Subscribe a handler to one of the scanner's events.
The handler is called with the event's values as keyword arguments:
channel_state:band,index,freq,is_active,snr_db,ctcss_hz,dcs_code.recording_started:band,index,freq.recording_saved:band,index,freq,file_path,ctcss_hz,dcs_code.recording_discarded:band,index,freq.noise_floor:noise_floor_db,warmup_complete.channel_snr:channels, a list with a dict for each radio channel, holding itsindex,frequency_mhz,snr_db,is_activeandduration_active_s.
Every value is a plain Python type (bool, int, float, str, or None), so a
payload can go straight to json.dumps.
When they fire:
channel_statefires when a radio channel turns ON or OFF. On a recording band,recording_startedfor the same activation comes first, becausechannel_statewaits for the demodulator so that it can carry the detected tone. Radio channels still ON when a scan ends get their OFF during the scan's cleanup.recording_savedorrecording_discardedfollows each recording's close, after that activation'schannel_stateOFF.noise_floorfires for every processed slice, warmup included.channel_snrfires for each slice that measures every radio channel: not during warmup, not for a slice dropped for ADC saturation, and not while the whole band is quiet with no radio channel ON.
ctcss_hz and dcs_code on channel_state carry a tone detected when a
radio channel turns ON, and are otherwise None; an OFF always carries None.
On recording_saved they carry whatever tone was detected during the
activation, or None. Tone detection has not yet been thoroughly tested
with real radios.
Handlers may be plain functions or coroutine functions. A coroutine
function always runs on the scanner's event loop, and the end of a scan
waits for it. A plain function runs on the event loop for
channel_state and recording_started, and on the thread that emits the
event for the others: the processing thread for noise_floor and
channel_snr. See emit() for the details.
Parameters
event: The event's name, one of those above. A name no event has is accepted, and its handler is never called.handler: A function or coroutine function taking the event's values as keyword arguments.
RadioScanner.off
RadioScanner.off(event: str, handler: typing.Callable) -> None
Unsubscribe a handler from an event. A handler that was not subscribed is ignored.
Parameters
event: The event's name.handler: The handler given toon().
RadioScanner.emit
RadioScanner.emit(
event: str,
loop: asyncio.AbstractEventLoop | None = None,
**kwargs: typing.Any,
) -> None
Fire all handlers registered for an event. The scanner calls this itself;
a program embedding it subscribes with on().
How each handler is called, by its kind and whether loop is given:
- A coroutine function is scheduled with
asyncio.run_coroutine_threadsafe()onloop, or on the scanner's own loop when none is given, and awaited there. With neither, as before a scan has started, it is skipped, becauseemit()is not async and cannot await. - A plain function, with
loopgiven, is called throughloop.call_soon_threadsafe(). - A plain function, with no
loop, is called directly on the caller's thread, which is howemit()is used from the event loop itself.
A handler's exception is caught wherever the handler runs, and logged at WARNING with its traceback the first time that handler fails, then at DEBUG. A failing handler never stops the other handlers firing.
Parameters
event: The event's name.loop: The event loop to call handlers on, when called from another thread.**kwargs: The event's values, passed to each handler as keyword arguments.
RadioScanner.add_state_callback
RadioScanner.add_state_callback(callback: typing.Callable) -> None
Call a function each time a radio channel turns ON or OFF: the
channel_state event, in a simpler form.
The function receives band_name, channel_index, is_active and
snr_db as positional arguments. For the radio channel's frequency or a
detected tone, subscribe to channel_state with on() instead.
Parameters
callback: A plain function, not a coroutine function, taking those four arguments.
RadioScanner.add_recording_callback
RadioScanner.add_recording_callback(callback: typing.Callable) -> None
Call a function each time a recording is saved: the recording_saved
event, in a simpler form. It is not called for a recording the noise
checks discard.
The function receives band_name, channel_index and file_path as
positional arguments. For the radio channel's frequency or a detected
tone, subscribe to recording_saved with on() instead.
Parameters
callback: A plain function, not a coroutine function, taking those three arguments.
RadioScanner.scan
async RadioScanner.scan() -> None
Scan until cancelled, or until an IQ file has been played to its end.
It opens the receiver, streams IQ samples from it in the background, and processes each slice on a worker thread, so the event loop stays free for the program embedding it.
Returns when an IQ file has been played to its end, or when the scan is cancelled, as Ctrl+C cancels it. Anything that stops the scan early is raised once the recordings and the device have been closed: an error opening or configuring the device, an error processing a slice, or a live device that stops streaming, which raises the error it failed with, or RuntimeError when it gave none.