hfmodemDocumentation

monitor_modes.py

# SPDX-License-Identifier: AGPL-3.0-only
# SPDX-FileCopyrightText: 2026 Saul St John (W9SSJ)
"""Receive-only console profiles, independent of the modes that can fly arms."""
from dataclasses import replace
import math

from creance.monitor.driver import MonitorSpec, RUNNERS, MODEM_ROOT, _interpreter

# None means that the preamble identifies the mode. Other defaults are explicit
# operator choices; 4285, 4529, the parallel tones and FSK cannot infer them.
WAVEFORMS = {
    'mil110': ('110', None), 'stanag4285': ('4285', '1200L'),
    'stanag4529': ('4529', '600L'), 'stanag4539': ('4539', None),
    'wbhf': ('WBHF', None), 'tone39': ('39', '600/33/A10.8'),
    'tone16': ('16', '1200'), 'fsk': ('FSK', '75W'),
}
NAMES = ('pactor', 'pactor4', 'vara', 'ardop', 'rtty', 'sitor-b', 'navtex',
         'sabir', 'rpr', 'ale', 'selcall', *WAVEFORMS, 'mod110', 'cw', 'stanag')
ALIASES = {
    'shrike': 'pactor', 'p4': 'pactor4', 'kestrel': 'vara', 'besra': 'ardop',
    'sitor': 'sitor-b', 'lanner': 'rtty', 'robust-packet': 'rpr', 'brubru': 'rpr',
    '110': 'mil110', 'mil-std-188-110': 'mil110', '4285': 'stanag4285',
    '4529': 'stanag4529', '4539': 'stanag4539', '39': 'tone39', '16': 'tone16',
    'bateleur': 'stanag', 'stang': 'stanag',
}


def canonical(name):
    name = name.lower()
    name = ALIASES.get(name, name)
    if name not in NAMES:
        raise ValueError(f'Unknown monitor {name!r}; choose {", ".join(NAMES)}.')
    return name


def selected(args, defaults):
    """Build only the requested decoders, validating every setting before CAT."""
    name = canonical(args.monitor_mode)
    waveform = getattr(args, 'mode', None)
    framing = getattr(args, 'framing', 'SYNC').upper()
    order = getattr(args, 'bit_order', 'lsb')
    centre = getattr(args, 'audio_centre', 700.0)
    if not math.isfinite(centre) or not 100 <= centre <= 4000:
        raise ValueError('--audio-centre must be between 100 and 4000 Hz.')
    if name != 'cw' and centre != 700:
        raise ValueError('--audio-centre is the CW tone, in Hz.')
    if name not in WAVEFORMS and name != 'stanag' and (waveform or framing != 'SYNC' or order != 'lsb'):
        raise ValueError('--mode, --framing and --bit-order apply to Bateleur waveforms.')
    if name in ('pactor', 'pactor4', 'vara', 'ardop'):
        runners = {'pactor': ('shrike', 'p4'), 'pactor4': ('p4',),
                   'vara': ('kestrel',), 'ardop': ('besra',)}[name]
        return [s for s in defaults if s.name in runners]
    if name in ('rtty', 'sitor-b', 'navtex'):
        lanner = next(s for s in defaults if s.name == 'lanner')
        return [replace(lanner, args=(*lanner.args, '--protocol', name.upper(),
                                     '--search-span', '2500'))]
    options = ['--protocol', name, '--audio-centre', str(centre)]
    if name in WAVEFORMS or name == 'stanag':
        from hfmodem.bateleur.modem import Mode, ASYNC_STANDARDS
        from hfmodem.bateleur.charframe import CharFormat
        if name == 'stanag':
            parts = (waveform or '4285 1200L').split(maxsplit=1)
            if len(parts) != 2:
                raise ValueError("--mode for stanag needs a standard and mode, e.g. '4285 1200L'.")
            standard, waveform = parts
        else:
            standard, default = WAVEFORMS[name]
            waveform = waveform or default
        # Self-identifying receivers do not use a supplied rate restriction.
        if standard.upper() in ('110', '4539', 'WBHF') and waveform is not None:
            raise ValueError(f'{standard} identifies its rate in the preamble; omit --mode.')
        if waveform is not None:
            # Receive placement is independent of the station's TX passband.
            # These waveform parsers accept every supported sub-carrier;
            # Mode.parse additionally enforces the transmitter's 3 kHz span.
            if standard.upper() == 'FSK':
                from hfmodem.bateleur import fsk
                standard, waveform = 'FSK', fsk.mode(waveform).name
            elif standard == '4529':
                from hfmodem.bateleur import s4529
                waveform = s4529.mode(waveform).full_name
            else:
                mode = Mode.parse(standard, waveform)
                standard, waveform = mode.standard, mode.name
        elif standard.upper() not in ('110', '4539', 'WBHF'):
            raise ValueError(f'{standard} needs a receive mode.')
        standard = standard.upper()
        if framing != 'SYNC':
            if standard not in ASYNC_STANDARDS:
                raise ValueError(f'{standard} has its own character framing; omit --framing.')
            framing = CharFormat.parse(framing).name
        options += ['--standard', standard, '--bit-order', order, '--framing', framing]
        if waveform:
            options += ['--mode', waveform]
    return [MonitorSpec(name, _interpreter(), str(RUNNERS / 'single_runner.py'),
                        defaults[0].cwd if defaults else str(MODEM_ROOT), tuple(options))]


def peer_frequency(args):
    """A named channel is a startup dial shortcut, never a callsign filter."""
    from . import modes, roster
    name = getattr(args, 'peer', None)
    if not name:
        return
    if args.freq is not None or args.wav or args.arm:
        raise ValueError('A peer selects a receiver frequency; give a peer or --freq, not both, '
                         'and omit peers for recordings.')
    candidates = []
    profile = canonical(args.monitor_mode)
    for mode_name in ((profile,) if profile in modes.KNOWN else modes.KNOWN):
        try:
            peers = roster.peers(modes.resolve(mode_name))
        except FileNotFoundError:
            continue
        candidates.extend(p for p in peers if name.upper() in (p['id'].upper(), p['call'].upper())
                          and (args.band is None or p['band_m'] == args.band))
    frequencies = {p['dial_hz'] for p in candidates}
    if len(frequencies) != 1:
        choices = ', '.join(sorted({p['id'] for p in candidates})) or 'no matching saved channels'
        raise ValueError(f'Peer {name!r} must name one frequency: {choices}. '
                         'Use a full peer ID, --band, or --freq KHZ.')
    args.freq = frequencies.pop() / 1000
    args.monitor_peer = name