diff --git a/drb-edge-node/app/internal/radio_config.py b/drb-edge-node/app/internal/radio_config.py
index 08b853b..4dc0892 100644
--- a/drb-edge-node/app/internal/radio_config.py
+++ b/drb-edge-node/app/internal/radio_config.py
@@ -9,7 +9,11 @@ C2 system documents (the `config` blob is passed through untouched):
P25 conventional type "P25", config.mode "conventional",
config.channels = [{freq: MHz, name, nac?}, ...] (node-26#24)
Analog type "NBFM", config.channels = [{freq, name}] — or, from
- before #24, config.control_channels[0]
+ before #24, config.control_channels[0]. Optional squelch
+ (node-26#46): config.squelch_mode "noise" (default) |
+ "voice" | "power", config.squelch_db (noise/voice: dB
+ of quieting to open) and config.squelch_threshold
+ (power: absolute dB, OP25 default -60)
A missing mode is trunked, so every system saved before #24 behaves exactly as
it did. Pure functions; main._generate_op25_config does the I/O.
@@ -80,6 +84,41 @@ def conventional_channels(config: Dict[str, Any]) -> List[Dict[str, Any]]:
return out
+SQUELCH_MODES = ("noise", "voice", "power")
+DEFAULT_SQUELCH_MODE = "noise"
+
+
+def _optional_float(config: Dict[str, Any], key: str) -> Optional[float]:
+ value = (config or {}).get(key)
+ if value is None or (isinstance(value, str) and not value.strip()):
+ return None
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ raise ValueError(f"{key} {value!r} isn't a number.")
+
+
+def analog_squelch(config: Dict[str, Any]) -> Dict[str, Any]:
+ """config -> the op25-container squelch fields for an analog channel.
+
+ Noise squelch by default (node-26#46): OP25's power squelch compares an
+ absolute level that depends on dongle, gain and antenna, and left analog
+ open on static; noise squelch measures FM quieting and needs no
+ calibration. A config that says nothing gets noise mode.
+ """
+ mode = str((config or {}).get("squelch_mode") or DEFAULT_SQUELCH_MODE).strip().lower()
+ if mode not in SQUELCH_MODES:
+ raise ValueError(f"Squelch mode {mode!r} isn't one of {', '.join(SQUELCH_MODES)}.")
+ out: Dict[str, Any] = {"nbfm_squelch_mode": mode}
+ db = _optional_float(config, "squelch_db")
+ if db is not None:
+ out["nbfm_noise_squelch_db"] = db
+ threshold = _optional_float(config, "squelch_threshold")
+ if threshold is not None:
+ out["nbfm_squelch"] = threshold
+ return out
+
+
def analog_frequency(config: Dict[str, Any]) -> Optional[int]:
chans = (config or {}).get("channels") or []
if chans and isinstance(chans[0], dict) and chans[0].get("freq") not in (None, ""):
@@ -117,6 +156,7 @@ def build_generate_payload(
if mode == ANALOG:
freq = analog_frequency(config)
payload["channels"] = [freq] if freq else []
+ payload.update(analog_squelch(config))
return payload
talkgroups = (config or {}).get("talkgroups", []) or []
diff --git a/drb-edge-node/app/routers/api.py b/drb-edge-node/app/routers/api.py
index dddc78e..f0a8fbd 100644
--- a/drb-edge-node/app/routers/api.py
+++ b/drb-edge-node/app/routers/api.py
@@ -208,6 +208,12 @@ class ListenBody(BaseModel):
frequencies: List[float] = []
talkgroups: List[Talkgroup] = [] # p25 only; empty = every talkgroup
nac: Optional[str] = None # p25conv only, hex; empty = any NAC
+ # analog only (node-26#46): OP25 squelch. noise (default) needs no
+ # calibration; squelch_db = dB of quieting to open (OP25 default 8);
+ # squelch_threshold = absolute dB for power mode (OP25 default -60).
+ squelch_mode: Optional[Literal["noise", "voice", "power"]] = None
+ squelch_db: Optional[float] = None
+ squelch_threshold: Optional[float] = None
# RTL-SDR (R820T/R828D) tuning range.
@@ -242,14 +248,21 @@ async def listen(body: ListenBody):
else:
is_p25 = body.mode == "p25"
label = (body.name or "").strip() or (f"P25 {first} MHz" if is_p25 else f"Analog {first} MHz")
+ sys_config = {
+ "control_channels": freqs,
+ "talkgroups": [tg.model_dump() for tg in body.talkgroups] if is_p25 else [],
+ }
+ if not is_p25:
+ sys_config["squelch_mode"] = body.squelch_mode or "noise"
+ if body.squelch_db is not None:
+ sys_config["squelch_db"] = body.squelch_db
+ if body.squelch_threshold is not None:
+ sys_config["squelch_threshold"] = body.squelch_threshold
config = SystemConfig(
system_id=f"local-{body.mode}",
name=label,
type="P25" if is_p25 else "NBFM",
- config={
- "control_channels": freqs,
- "talkgroups": [tg.model_dump() for tg in body.talkgroups] if is_p25 else [],
- },
+ config=sys_config,
)
await _apply_override(config)
return {"ok": True, "name": config.name}
diff --git a/drb-edge-node/app/templates/index.html b/drb-edge-node/app/templates/index.html
index 8847e2d..39e2333 100644
--- a/drb-edge-node/app/templates/index.html
+++ b/drb-edge-node/app/templates/index.html
@@ -216,6 +216,20 @@
+
+
+
+ Noise squelch opens on a real carrier and needs no tuning per site.
+
+
+
+
+ Noise/voice: dB of quieting to open — 8 default, 12-15 for strong signals only, ~5 for weak ones.
+
@@ -396,6 +410,13 @@
// ── Listen locally ─────────────────────────────────────────────────────
let systems = [];
function mode() { return document.querySelector('input[name=mode]:checked').value; }
+ $('an-sq-mode').addEventListener('change', () => {
+ const power = $('an-sq-mode').value === 'power';
+ $('an-sq-level').placeholder = power ? '-60' : '8';
+ $('an-sq-hint').textContent = power
+ ? 'Power: absolute signal level in dB to open (-60 default); depends on this SDR, gain and antenna.'
+ : 'Noise/voice: dB of quieting to open — 8 default, 12-15 for strong signals only, ~5 for weak ones.';
+ });
document.querySelectorAll('input[name=mode]').forEach(r => r.addEventListener('change', () => {
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.dataset.pane !== mode(); });
$('listen-msg').textContent = '';
@@ -443,7 +464,12 @@
} else {
const freqs = parseFreqs($('an-freq').value);
if (freqs.length !== 1 || isNaN(freqs[0])) { msg.className = 'msg err'; msg.textContent = 'Enter one frequency in MHz, e.g. 154.4300'; return; }
- body = { mode: 'analog', frequencies: freqs, name: $('an-name').value };
+ const sqMode = $('an-sq-mode').value;
+ const sqRaw = $('an-sq-level').value.trim();
+ const sqLevel = sqRaw === '' ? null : Number(sqRaw);
+ if (sqLevel !== null && isNaN(sqLevel)) { msg.className = 'msg err'; msg.textContent = 'Squelch level must be a number of dB, e.g. 8 (or -60 for power mode).'; return; }
+ body = { mode: 'analog', frequencies: freqs, name: $('an-name').value, squelch_mode: sqMode };
+ if (sqLevel !== null) body[sqMode === 'power' ? 'squelch_threshold' : 'squelch_db'] = sqLevel;
}
$('listen-btn').disabled = true;
msg.className = 'msg';
diff --git a/drb-edge-node/tests/test_conventional.py b/drb-edge-node/tests/test_conventional.py
index c4e2c5c..635b849 100644
--- a/drb-edge-node/tests/test_conventional.py
+++ b/drb-edge-node/tests/test_conventional.py
@@ -78,6 +78,17 @@ def test_analog_payload_reads_channels_or_legacy(cfg):
p = rc.build_generate_payload("NBFM", "Fire", cfg, ICE, "rtl-sdr-v3", None)
assert p["channels"] == [154_430_000]
assert "p25_mode" not in p
+ assert p["nbfm_squelch_mode"] == "noise" # node-26#46 default
+
+
+def test_analog_squelch_is_plumbed_and_validated():
+ cfg = {"channels": [{"freq": 154.43}], "squelch_mode": "Power", "squelch_threshold": "-55", "squelch_db": 12}
+ p = rc.build_generate_payload("NBFM", "Fire", cfg, ICE, "rtl-sdr-v3", None)
+ assert p["nbfm_squelch_mode"] == "power" and p["nbfm_squelch"] == -55.0 and p["nbfm_noise_squelch_db"] == 12.0
+ with pytest.raises(ValueError):
+ rc.analog_squelch({"squelch_mode": "loud"})
+ with pytest.raises(ValueError):
+ rc.analog_squelch({"squelch_db": "lots"})
def test_system_mode():
diff --git a/drb-edge-node/tests/test_local_dashboard.py b/drb-edge-node/tests/test_local_dashboard.py
index e0ffe69..e39f3bf 100644
--- a/drb-edge-node/tests/test_local_dashboard.py
+++ b/drb-edge-node/tests/test_local_dashboard.py
@@ -229,6 +229,17 @@ def test_listen_analog_single_frequency(client, op25):
assert r.status_code == 200, r.text
cfg = config_manager.load_node_config().override_config
assert (cfg.type, cfg.name, cfg.config["control_channels"]) == ("NBFM", "Fire", [154.43])
+ assert cfg.config["squelch_mode"] == "noise" # node-26#46 default
+
+
+def test_listen_analog_squelch_is_saved(client, op25):
+ r = client.post("/api/listen", json={"mode": "analog", "frequencies": [154.43],
+ "squelch_mode": "power", "squelch_threshold": -55})
+ assert r.status_code == 200, r.text
+ cfg = config_manager.load_node_config().override_config.config
+ assert (cfg["squelch_mode"], cfg["squelch_threshold"]) == ("power", -55)
+ assert client.post("/api/listen", json={"mode": "analog", "frequencies": [154.43],
+ "squelch_mode": "loud"}).status_code == 422
def test_listen_p25_conventional(client, op25):
diff --git a/op25-container/Dockerfile b/op25-container/Dockerfile
index 111beb6..51091e8 100644
--- a/op25-container/Dockerfile
+++ b/op25-container/Dockerfile
@@ -1,66 +1,74 @@
-# OP25 Core Container
-# Pinned to a Python major version deliberately. The bare `slim-trixie` tag
-# carries no version at all, so a rebuild could move the interpreter across a
-# major release -- which this repo has already been bitten by once, when
-# app/models.py only ran because trixie happened to ship 3.14 and PEP 649
-# defers annotation evaluation. Matches drb-edge-node, which is already 3.14.
-FROM python:3.14-slim
-
-# Set environment variables
-ENV DEBIAN_FRONTEND=noninteractive
-
-# Install system dependencies
-RUN apt-get update && \
- apt-get upgrade -y && \
- apt-get install git pulseaudio pulseaudio-utils liquidsoap usbutils -y
-
-# Install custom PulseAudio system config (enables anonymous access for edge-node)
-COPY system.pa /etc/pulse/system.pa
-
-# Clone the boatbod op25 repository
-RUN git clone -b gr310 https://github.com/boatbod/op25 /op25
-
-# Set the working directory
-WORKDIR /op25
-
-# Run the install script to set up op25
-RUN sed -i 's/sudo //g' install.sh
-RUN ./install.sh -f
-
-# Install Python dependencies
-COPY requirements.txt /tmp/requirements.txt
-RUN pip3 install --no-cache-dir -r /tmp/requirements.txt
-
-# DRB's trunking module for P25 conventional channels (node-26#24): reports
-# encryption + NAC and applies the NAC filter, which stock tk_p25 does not.
-COPY op25_addons/tk_drb_conv.py /op25/op25/gr-op25_repeater/apps/tk_drb_conv.py
-
-# Create the run_multi-rx_service.sh script
-COPY run_multi-rx_service.sh /op25/op25/gr-op25_repeater/apps/run_multi-rx_service.sh
-RUN chmod +x /op25/op25/gr-op25_repeater/apps/run_multi-rx_service.sh
-
-# Expose ports for HTTP control as needed, for example:
-EXPOSE 8001 8081
-
-# Create and set up the configuration directory
-VOLUME ["/configs"]
-
-# Set the working directory in the container
-WORKDIR /app
-
-# Copy the rest of the directory contents into the container at /app
-COPY ./app /app
-
-# 1. Copy the wrapper script and make it executable
-COPY docker-entrypoint.sh /usr/local/bin/
-RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && \
- chmod +x /usr/local/bin/docker-entrypoint.sh
-
-# 2. Update ENTRYPOINT to use the wrapper script
-ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
-
-# 3. Use CMD to pass the launch command as arguments to the ENTRYPOINT script.
-# main.py starts uvicorn itself (see `if __name__ == "__main__"`) so the bind
-# address can be driven by OP25_DEBUG_EXPOSE at runtime instead of being baked
-# into this image at build time.
+# OP25 Core Container
+# Pinned to a Python major version deliberately. The bare `slim-trixie` tag
+# carries no version at all, so a rebuild could move the interpreter across a
+# major release -- which this repo has already been bitten by once, when
+# app/models.py only ran because trixie happened to ship 3.14 and PEP 649
+# defers annotation evaluation. Matches drb-edge-node, which is already 3.14.
+FROM python:3.14-slim
+
+# Set environment variables
+ENV DEBIAN_FRONTEND=noninteractive
+
+# Install system dependencies
+RUN apt-get update && \
+ apt-get upgrade -y && \
+ apt-get install git pulseaudio pulseaudio-utils liquidsoap usbutils -y
+
+# Install custom PulseAudio system config (enables anonymous access for edge-node)
+COPY system.pa /etc/pulse/system.pa
+
+# Clone the boatbod op25 repository, PINNED (node-26#29, #46). An unpinned
+# clone plus CI's layer cache meant the image held whatever gr310 HEAD was at
+# the last cache miss. 5b58335 (gr310, 2026-08-23) is the commit tk_drb_conv
+# was verified against for node-26#29 and the first gr310 merge that carries
+# the 2026-08-03 NBFM noise/voice squelch (PR #296, op25_nbfm.py
+# nbfm_squelch_mode). Bump deliberately, re-checking tk_drb_conv against
+# tk_p25.py at the new commit.
+ARG OP25_COMMIT=5b583357240e0172bfae905f0c966474e0d0f094
+RUN git clone -b gr310 https://github.com/boatbod/op25 /op25 && \
+ git -C /op25 checkout --detach "${OP25_COMMIT}"
+
+# Set the working directory
+WORKDIR /op25
+
+# Run the install script to set up op25
+RUN sed -i 's/sudo //g' install.sh
+RUN ./install.sh -f
+
+# Install Python dependencies
+COPY requirements.txt /tmp/requirements.txt
+RUN pip3 install --no-cache-dir -r /tmp/requirements.txt
+
+# DRB's trunking module for P25 conventional channels (node-26#24): reports
+# encryption + NAC and applies the NAC filter, which stock tk_p25 does not.
+COPY op25_addons/tk_drb_conv.py /op25/op25/gr-op25_repeater/apps/tk_drb_conv.py
+
+# Create the run_multi-rx_service.sh script
+COPY run_multi-rx_service.sh /op25/op25/gr-op25_repeater/apps/run_multi-rx_service.sh
+RUN chmod +x /op25/op25/gr-op25_repeater/apps/run_multi-rx_service.sh
+
+# Expose ports for HTTP control as needed, for example:
+EXPOSE 8001 8081
+
+# Create and set up the configuration directory
+VOLUME ["/configs"]
+
+# Set the working directory in the container
+WORKDIR /app
+
+# Copy the rest of the directory contents into the container at /app
+COPY ./app /app
+
+# 1. Copy the wrapper script and make it executable
+COPY docker-entrypoint.sh /usr/local/bin/
+RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && \
+ chmod +x /usr/local/bin/docker-entrypoint.sh
+
+# 2. Update ENTRYPOINT to use the wrapper script
+ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+
+# 3. Use CMD to pass the launch command as arguments to the ENTRYPOINT script.
+# main.py starts uvicorn itself (see `if __name__ == "__main__"`) so the bind
+# address can be driven by OP25_DEBUG_EXPOSE at runtime instead of being baked
+# into this image at build time.
CMD ["python", "main.py"]
\ No newline at end of file
diff --git a/op25-container/app/models.py b/op25-container/app/models.py
index de3f867..242ac1d 100644
--- a/op25-container/app/models.py
+++ b/op25-container/app/models.py
@@ -54,6 +54,13 @@ class P25Mode(str, Enum):
CONVENTIONAL = "conventional"
+class NbfmSquelchMode(str, Enum):
+ """Values op25_nbfm.py accepts for nbfm_squelch_mode (gr310, 2026-08-03)."""
+ POWER = "power"
+ NOISE = "noise"
+ VOICE = "voice"
+
+
class ConventionalChannel(BaseModel):
"""One fixed P25 conventional voice channel (node-26#24)."""
frequency: int # Hz
@@ -77,7 +84,16 @@ class ConfigGenerator(BaseModel):
hardware_preset: str = "rtl-sdr-v3"
ppm_override: Optional[float] = None # when set, overrides the preset's default PPM
# NBFM only: power squelch threshold in dB (op25_nbfm default is -60).
+ # Used only in "power" squelch mode.
nbfm_squelch: Optional[float] = None
+ # NBFM only (node-26#46): OP25 gr310's squelch algorithm — "power" (the
+ # absolute-level squelch, needs per-site calibration), "noise" (FM
+ # quieting, no calibration) or "voice" (noise + speech detector).
+ # Defaults to noise: power mode left analog open on static.
+ nbfm_squelch_mode: NbfmSquelchMode = NbfmSquelchMode.NOISE
+ # dB of quieting needed to open in noise/voice mode (OP25 default 8;
+ # 12-15 opens only on solid signals, ~5 chases weak ones).
+ nbfm_noise_squelch_db: Optional[float] = None
class DemodType(str, Enum):
CQPSK = "cqpsk"
@@ -99,6 +115,9 @@ class ChannelConfig(BaseModel):
frequency: Optional[int] = None
# Key name op25_nbfm.py actually reads (from_dict(config, 'nbfm_squelch_threshold', -60)).
nbfm_squelch_threshold: Optional[float] = None
+ # Also read by op25_nbfm.py from the channel dict (node-26#46).
+ nbfm_squelch_mode: Optional[str] = None
+ nbfm_noise_squelch_db: Optional[float] = None
destination: Optional[str] = "udp://127.0.0.1:23456"
# P25 conventional only, read by tk_drb_conv.py (not by multi_rx): the NAC
# to lock the frame assembler to. multi_rx passes the whole channel dict
diff --git a/op25-container/app/routers/op25_controller.py b/op25-container/app/routers/op25_controller.py
index 4e3dd2e..480718d 100644
--- a/op25-container/app/routers/op25_controller.py
+++ b/op25-container/app/routers/op25_controller.py
@@ -262,6 +262,8 @@ def create_op25_router():
frequency=freq,
meta_stream_name="",
nbfm_squelch_threshold=generator.nbfm_squelch,
+ nbfm_squelch_mode=generator.nbfm_squelch_mode.value,
+ nbfm_noise_squelch_db=generator.nbfm_noise_squelch_db,
)]
devices = [DeviceConfig(
gains=preset["gains"], ppm=ppm, tunable=False,
diff --git a/op25-container/tests/test_conventional.py b/op25-container/tests/test_conventional.py
index cace7a6..4ce2498 100644
--- a/op25-container/tests/test_conventional.py
+++ b/op25-container/tests/test_conventional.py
@@ -149,6 +149,23 @@ def test_generate_config_route(tmp_path, monkeypatch):
cfg = json.loads((tmp_path / "active.cfg.json").read_text())
assert cfg["trunking"]["module"] == "tk_drb_conv.py"
assert cfg["channels"][0]["frequency"] == 154430000
+ # node-26#46: analog defaults to OP25's noise squelch, not power.
+ assert cfg["channels"][0]["nbfm_squelch_mode"] == "noise"
+ assert "nbfm_noise_squelch_db" not in cfg["channels"][0]
+
+ r = client.post("/op25/generate-config", json={
+ "type": "NBFM", "systemName": "Fire", "channels": [154430000], "icecastConfig": ice,
+ "nbfm_squelch_mode": "power", "nbfm_squelch": -55, "nbfm_noise_squelch_db": 12,
+ })
+ assert r.status_code == 200, r.text
+ ch = json.loads((tmp_path / "active.cfg.json").read_text())["channels"][0]
+ assert ch["nbfm_squelch_mode"] == "power" and ch["nbfm_squelch_threshold"] == -55
+ assert ch["nbfm_noise_squelch_db"] == 12
+
+ r = client.post("/op25/generate-config", json={
+ "type": "NBFM", "systemName": "Fire", "channels": [154430000], "nbfm_squelch_mode": "loud",
+ })
+ assert r.status_code == 422
# ---------------------------------------------------------------------------