Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 48 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ Prefix all commands with `uv run apps/ham2mon.py` (which runs the script using t
uv run apps/ham2mon.py [options]
```

Not sure how to run ham2mon for what you're trying to do? The [Scanning Guide](./doc/scanning-guide.md) walks through every mode of operation — from a bare sweep with no frequency file to bank-filtered monitoring — what each one is for, and what you'll see on screen.

## Console Operation:
The following is an example of the option switches for UHD with NBFM demodulation, although omission of any will use default values (shown below) that are optimal for the B200:

Expand Down Expand Up @@ -209,6 +211,8 @@ uv run apps/ham2mon.py -a "file=gqrx.raw,rate=8E6,repeat=false,throttle=true,fre

`/ = Frequency entry mode (Esc to exit)`

`b = Edit active banks (Enter to apply, Esc to cancel)`

`CTRL-C or SHIFT-Q = quit`

> [!IMPORTANT]
Expand Down Expand Up @@ -484,11 +488,41 @@ The frequency file contains metadata for individual frequencies and ranges of fr
2. Lockout frequencies
3. Frequency labeling
4. CTCSS (PL tone) filtering
5. Bank tags

If an individual frequency or frequency range is specified more than once, an error will be generated and ham2mon will not load (unless the duplicate entry is used to specify an
additional unique ctcss tone for that frequency).

For an example, see the [example frequencies file](./doc/example.freqs.yaml).
For an example, see the [example frequencies file](./doc/example.freqs.yaml). For a build-up from the bare minimum through every configuration option, see the [full example frequencies file](./doc/full-example.freqs.yaml).

### Bank Filtering (`--banks`)
Banks are optional tags applied to frequency entries and to per-tone rules in the frequency file:

- label: "Local repeater output"
single: 462.730
banks: ["NET_A"]

Select which banks to monitor with `--banks` (or `frequency_policies.active_banks` in YAML):

uv run apps/ham2mon.py -a "airspy" -f 460.0-470.0 --banks NET_A NET_B

`--banks` is a **filter**, not a scan-scope control. The scanner still sweeps the entire configured band (or range) every scan cycle; bank filtering only controls which channels are demodulated and which captured transmissions are kept. A channel whose resolved bank tags do not intersect the selected set is never assigned a demodulator, and a transmission already captured on such a channel is discarded.

Bank filtering is fail-closed: if a `--banks` tag matches no configured frequency or tone bank, no channel is demodulated and ham2mon logs a startup warning. Without `--banks`, all channels are monitored. Two special tags are available: `SEARCH` lets unconfigured spectrum hits be monitored, and `UNTAGGED` matches channels that carry no bank tag while filtering is active.

The RECEIVER panel shows the active selection in its **Banks** row (`NET_A, NET_B`, or `none` without `--banks`), and each CHANNELS entry displays its resolved tags as a bracketed block just before the CTCSS readout (e.g. `[NET_A,NET_B]`), dimmed while the channel is idle. `SEARCH` and `UNTAGGED` appear as literal tags here only while bank filtering is active.

A bank tag can be written as a dict mapping the tag to a **per-bank display label** (`banks: {AREA_A: "Net A", AREA_B: "Net B"}`). The keys are the membership tags, exactly like the list form; the values are per-bank label overrides. When a hit resolves to one of those banks, its label is shown instead of the entry label (this is how one frequency reads differently per geographic area). Label precedence for a matched entry is: per-bank label, then per-tone label, then the entry label. An optional top-level `banks:` section in the frequency file attaches display-only names to tags for the RECEIVER Banks row (e.g. `BANKS: NET_A (Net A)`); it does not declare membership. See the [full example frequencies file](./doc/full-example.freqs.yaml).

Use `--list-banks` to audit bank membership without running the scanner: it loads the frequency file, prints each configured bank with its channel members (using the top-level display label when present), and exits.

For the full set of scanning modes — including running with no frequency file, and custom bank combinations such as `NET_A SEARCH` or a default bank for a range with specific-channel overrides — see the [Scanning Guide](./doc/scanning-guide.md).

#### Changing banks at runtime

Press `b` to edit the active banks in place: the Banks row switches to an editable text field pre-filled with the current selection. Type a comma- or space-separated list (letters, digits, `_`, `-`, `,`, space) and press `Enter` to apply, or `Esc` to cancel. Submitting an empty list (or the literal `none`) restores promiscuous mode.

The change applies immediately on the next scan cycle: channels whose resolved tags no longer intersect the new selection are no longer assigned a demodulator, and any transmission still running on a now-deselected bank finishes naturally but its recording is discarded. Runtime changes are not persisted and do not affect the `--banks` setting for the next launch.

### Priority Handling
Priorities can be assigned to frequencies and frequency ranges in the frequency file. Highest priority is 1. Frequencies can have equal priority. If no priority is assigned the default value is no priority.
Expand Down Expand Up @@ -597,40 +631,42 @@ CTCSS (Continuous Tone-Coded Squelch System) allows filtering transmissions by r

### Configuration

CTCSS tones are configured per-channel inside the YAML frequencies file (specified via the `-F`/`--frequencies` command-line option):
CTCSS tones are configured per-channel inside the YAML frequencies file (specified via the `-F`/`--frequencies` command-line option) using the unified `tones:` key. Each list item is either a bare tone frequency in Hz or a tone rule dict with an optional per-tone label and per-tone banks:

```yaml
frequencies:
# Single tone, bare frequency in Hz
- label: "CTCSS Test Channel"
single: 144.500
ctcss: 100.0 # Configured expected CTCSS tone in Hz
tones: [100.0]
```

To support **multiple valid CTCSS tones** on a single frequency or frequency range, declare the frequency block multiple times, changing only the `ctcss` tone frequency. These will merge into a single tuner entry at load time:
To support **multiple valid CTCSS tones** on a single frequency or frequency range, list them all under `tones:` on a single entry:

```yaml
frequencies:
# Primary tone
- label: "CTCSS Test Channel"
single: 144.500
ctcss: 100.0
# Backup tone
- label: "CTCSS Test Channel"
single: 144.500
ctcss: 141.3
tones:
- ctcss: 100.0 # in Hz
label: "Primary"
- ctcss: 141.3
label: "Backup"
```

> Declaring the same frequency twice (even with different tones) is an error; multiple tones must live on one entry.

By default, **CTCSS demodulation is disabled (`--max-ctcss-tones` defaults to 0) for performance reasons**, as running CTCSS tone detection blocks on every channel incurs significant CPU overhead even when no signal is present. To enable CTCSS tone detection, you must specify a limit (e.g. `--max-ctcss-tones 3`). Any configured tones loaded beyond this limit are validated and rejected at configuration load time.

### User Options

Depending on your configuration in your `.freqs.yaml` file (specified via the `-F`/`--frequencies` option), the application operates in one of two modes:

1. **Carrier Squelch (CSQ) Mode (CTCSS Bypassed):**
* **Trigger:** Enabled for any channel configured in your `.freqs.yaml` file **without** a `ctcss` tone, or for any frequency not present in the file at all (such as dynamically discovered frequencies during a range scan).
* **Trigger:** Enabled for any channel configured in your `.freqs.yaml` file **without** a `tones:` entry, or for any frequency not present in the file at all (such as dynamically discovered frequencies during a range scan).
* **Behavior:** The receiver will record and unmute any signal that is strong enough to break the RF carrier power squelch, regardless of whether a sub-audible tone is present or what its frequency is. Additionally, the 300Hz high-pass filter is dynamically bypassed in this mode to preserve full audio fidelity and bass (e.g. for broadcast FM music).
2. **Tone Squelch (CTCSS) Mode:**
* **Trigger:** Enabled for channels configured **with** a specific `ctcss` key (e.g. `ctcss: 100.0`).
* **Trigger:** Enabled for channels configured with a `tones:` entry (e.g. `tones: [100.0]`).
* **Behavior:** The receiver will only unmute and keep the recording if the signal contains one of the configured CTCSS tones. Transmissions carrying a different tone or no tone at all are muted and discarded.

### GUI Display
Expand Down
31 changes: 18 additions & 13 deletions apps/channel_loggers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class ActivityParams:
'''
type: str
dest: str
interval: int
interval: float | int

class ActivityLogger(ABC):
'''
Expand All @@ -29,10 +29,10 @@ class ActivityLogger(ABC):
def __init__(self, params: ActivityParams,
get_ctcss: Callable[[int], float | None] | None = None) -> None:
logger.debug(f'Creating {self.__class__.__name__} channel logger')
self.interval: int = 0 # overridden by child classes
self.log_task: dict[int, asyncio.Task] = {} # activity logging tasks are channel specific
self.params = params
self.get_ctcss = get_ctcss # optional callback: bb_freq -> matched ctcss tone or None
self.interval: float | int = 0 # overridden by child classes
self.log_task: dict[int, asyncio.Task[None]] = {} # activity logging tasks are channel specific
self.params: ActivityParams = params
self.get_ctcss: Callable[[int], float | None] | None = get_ctcss # optional callback: bb_freq -> matched ctcss tone or None

async def log(self, msg: ChannelMessage | None,
record: TransmissionRecord | None = None) -> None:
Expand Down Expand Up @@ -102,7 +102,10 @@ async def log_active(self, msg: ChannelMessage) -> None:
rf=msg.rf,
bb=msg.bb,
channel=msg.channel,
matched_ctcss=live_ctcss))
matched_ctcss=live_ctcss,
label=msg.label,
priority=msg.priority,
banks=msg.banks))

class NoOp(ActivityLogger):
'''
Expand All @@ -112,7 +115,7 @@ def __init__(self, params: ActivityParams,
get_ctcss: Callable[[int], float | None] | None = None) -> None:
super().__init__(params, get_ctcss=get_ctcss)

self.interval: int = 0
self.interval: float | int = 0

async def log(self, msg: ChannelMessage | None,
record: TransmissionRecord | None = None) -> None:
Expand All @@ -125,12 +128,12 @@ class FixedField(ActivityLogger):
'''
Send channel events to a file with fixed field length records
'''
def __init__(self, params,
def __init__(self, params: ActivityParams,
get_ctcss: Callable[[int], float | None] | None = None) -> None:
super().__init__(params, get_ctcss=get_ctcss)

self.file_name = params.dest
self.interval = params.interval
self.file_name: str = params.dest
self.interval: float | int = params.interval

async def log(self, msg: ChannelMessage | None,
record: TransmissionRecord | None = None) -> None:
Expand All @@ -140,11 +143,13 @@ async def log(self, msg: ChannelMessage | None,
await super().log(msg)

now = datetime.datetime.now()
banks_str: str = ",".join(msg.banks) if msg.banks else ""
with open(self.file_name, 'a') as file:
text = (f'{now.strftime("%Y-%m-%d, %H:%M:%S.%f")}: {msg.state:<4}{msg.rf:<10}'
f'{msg.channel:<2}{msg.priority if msg.priority else "":<2}'
f'{msg.classification if msg.classification else "":<2}'
f'{f"{msg.matched_ctcss:.1f}" if msg.matched_ctcss else "":<7}'
f'{banks_str[:15]:<15}'
f'{msg.file if msg.file else "":<50}\n'
)
file.write(text)
Expand All @@ -155,12 +160,12 @@ class JsonToServer(ActivityLogger):
'''
Send channels events as json messages to a remote server
'''
def __init__(self, params,
def __init__(self, params: ActivityParams,
get_ctcss: Callable[[int], float | None] | None = None) -> None:
super().__init__(params, get_ctcss=get_ctcss)

self.server = params.dest
self.interval = params.interval
self.server: str = params.dest
self.interval: float | int = params.interval

self.requests = import_module('requests')
# urllib3 log suppression is configured at application startup in ham2mon.py
Expand Down
3 changes: 3 additions & 0 deletions apps/components/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ class ChannelInfo:
wav_tmp_path: str
"""Absolute path to the tmp WAV file being evaluated."""

banks: list[str] = field(default_factory=list)
"""Resolved scanner bank tags for this channel."""


class Component(ABC):
"""Base abstract class for all ham2mon components."""
Expand Down
3 changes: 2 additions & 1 deletion apps/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,12 @@ def __post_init__(self):

@dataclass(kw_only=True)
class FrequencyPoliciesConfig:
"""Frequencies file path, lockout settings, and priority overrides."""
"""Frequencies file path, lockout settings, priority overrides, and active banks."""

file: Optional[Path] = None
disable_lockout: bool = False
disable_priority: bool = False
active_banks: List[str] = field(default_factory=list)


@dataclass(kw_only=True)
Expand Down
85 changes: 80 additions & 5 deletions apps/cursesgui.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@
import logging
from pathlib import Path, PurePath
from frequency_manager import ConfigFrequency, ChannelFrequency, ChannelList, FrequencyList
from utilities import baseband_to_bin, build_column_edges, index_to_column
from utilities import (
baseband_to_bin,
build_column_edges,
format_active_banks,
format_channel_banks,
index_to_column,
)
from ui_theme import THEME, ThemeConfiguration

logger = logging.getLogger(f"ham2mon.{__name__}")
Expand Down Expand Up @@ -440,7 +446,7 @@ def draw(self) -> None:
label_start = 14

matched_ctcss = getattr(channel, 'matched_ctcss', None)
primary_ctcss = channel.ctcss or (channel.ctcss_tones[0] if channel.ctcss_tones else None)
primary_ctcss = channel.ctcss_tones[0] if channel.ctcss_tones else None

has_multiple_ctcss = len(channel.ctcss_tones) > 1
is_testing_ctcss = channel.active and not channel.hanging and has_multiple_ctcss and matched_ctcss is None
Expand All @@ -457,9 +463,26 @@ def draw(self) -> None:
ctcss_str = f'{display_ctcss:>5.1f}'
win.addnstr(row, col + self.width - 5, ctcss_str , 5, attributes[1] | curses.A_ITALIC)
win.addnstr(row, col + self.width - 6, ' ', 1, attributes[0])
label_end = self.width - 6
else:
label_end = self.width

# Right-align the bank tag block just before the CTCSS field (or
# the right border when no CTCSS), reserving its width from the
# label region. Empty banks draw nothing, so non-bank users see
# no layout shift.
ctcss_cols = 6 if display_ctcss is not None else 0
bank_end = self.width - ctcss_cols
bank_str = format_channel_banks(
getattr(channel, "banks", None) or [], max(0, bank_end - 1)
)
bank_start = bank_end - len(bank_str)
label_end = bank_start - 1 if bank_str else bank_end

if bank_str:
bank_attr = (
THEME.get("channel.bank_active")
if channel.active
else THEME.get("channel.bank_inactive")
)
win.addnstr(row, col + bank_start, bank_str, len(bank_str), bank_attr)

if label_end > label_start:
remainder = label_end - label_start
Expand Down Expand Up @@ -855,6 +878,9 @@ def __init__(self, screen, width=None):
self.freq_max = 148E6
self.samp_rate = 2E6
self.freq_entry = 'None'
self.banks: set[str] = set()
self.bank_labels: dict[str, str] = {}
self.bank_entry: str | None = None
self.squelch_db = -60
self.volume_db = 0
self.type_demod = 0
Expand All @@ -864,6 +890,9 @@ def __init__(self, screen, width=None):
self.activity_dest = ""
self.gains = None
self.classifier_params = None
# Declared here (initialized in draw_frame like the other *_field
# attributes) so the read-only Banks value can be rendered safely.
self.banks_field: RxWindow.RxEntry | None = None

self.demod_map = {
0: 'NBFM',
Expand Down Expand Up @@ -1038,6 +1067,8 @@ def draw_frame(self) -> None:
self.frequency_file_name_field = RxWindow.RxEntry(
"Freq File", 2, 'left', False)

self.banks_field = RxWindow.RxEntry("Banks", 2, "left", False)

self.activity_type_field = RxWindow.RxEntry(
"Activity Type", 2, 'left', False)

Expand Down Expand Up @@ -1082,6 +1113,13 @@ def draw_rx(self) -> None:
file_name = self.frequency_file_name.name if self.frequency_file_name else "none"
self.frequency_file_name_field.set(file_name)

if self.bank_entry is not None:
banks_text = self.bank_entry
else:
banks_text = format_active_banks(self.banks, self.bank_labels)
if self.banks_field is not None:
self.banks_field.set(banks_text)

self.activity_type_field.set(self.activity_type)

if self.activity_dest is not None:
Expand Down Expand Up @@ -1156,6 +1194,10 @@ def proc_keyb_hard(self, keyb: int):
# set mode to frequency entry
self.freq_entry = ''
return False
elif keyb == ord("b") and self.freq_entry == "None":
# set mode to bank entry (mutually exclusive with frequency entry)
self.start_bank_entry()
return False
elif keyb == 27: # ESC
# end frequncy entry mode without seting the frequency
self.freq_entry = 'None'
Expand All @@ -1178,6 +1220,39 @@ def proc_keyb_hard(self, keyb: int):
else:
return False

def start_bank_entry(self) -> None:
"""Enter bank-entry mode, pre-filled with the current active banks.

The live entry text is shown in the Banks receiver field; Enter
applies it (via the caller), ESC cancels.
"""
self.bank_entry = ",".join(sorted(self.banks))

def proc_keyb_bank_entry(self, keyb: int) -> bool:
"""Process keystrokes in bank-entry mode.

ESC cancels, Enter applies, Backspace deletes, and letters/digits/
'_'/'-'/','/space build up the entry. Returns True only when Enter
was pressed, so the caller can apply the parsed value. The entry
text is consumed (set to None) on both ESC and Enter.
"""
if keyb == 27: # ESC
self.bank_entry = None
return False
entry = self.bank_entry
if entry is None:
return False
if keyb == ord('\n'):
self.bank_entry = None
return True
if keyb == curses.KEY_BACKSPACE:
self.bank_entry = entry[:-1]
return False
if chr(keyb).isalnum() or chr(keyb) in " _,-":
self.bank_entry = entry + chr(keyb)
return False
return False

def _adjust_gain_stage(self, index: int, delta: float) -> bool:
if index < len(self.gains):
self.gains[index]["value"] += delta
Expand Down
Loading