Local dashboard: plain-language status checklist and C2-free listening

- /api/health: ordered checklist (password, C2 approval, MQTT, SDR, source,
  OP25, decoding, audio, Icecast) with what's wrong and what to do; pure
  build_checks() so every state (incl. Toughny's unapproved node) is tested
- /api/listen: pick P25 control channels / one analog freq / a cached C2
  system, validated in MHz, stored as a local override; OP25 restarted with
  the start failure reason surfaced
- /api/config/system no longer writes a raw blob OP25 can't read
- OP25 resumes after reboot on a locally chosen system, not only C2-assigned
- op25-container: analog (NBFM) config generation fixed (AttributeError +
  wrong field names), /op25/log tail, start refuses without a config,
  status detects a dead multi_rx ("crashed") -- node-26#12
- index.html rebuilt on UI_REDESIGN tokens; scanner drops DMR, shows System key

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 01:20:11 -04:00
co-authored by Claude Opus 5.5
parent 6bf2c9dd93
commit 10f7db0a2c
11 changed files with 1721 additions and 983 deletions
+154 -146
View File
@@ -1,147 +1,155 @@
from pydantic import BaseModel
from typing import List, Optional, Union
from enum import Enum
from config import bind_host
# Preset device settings for common RTL-SDR hardware.
# gains: OP25 gain string passed to the device block.
# ppm: frequency offset correction (user should calibrate and override).
# cqpsk_tracking: disable for TCXO dongles (RTL-SDR v3, NESDR) once PPM is dialled in;
# leave enabled for unknown hardware as a safety net.
HARDWARE_PRESETS: dict = {
"rtl-sdr-v3": {
"gains": "lna:34",
"ppm": 0.0,
"cqpsk_tracking": False,
},
"nesdr-smart-v4": {
"gains": "lna:32",
"ppm": 0.0,
"cqpsk_tracking": False,
},
"other": {
"gains": "lna:32",
"ppm": 0.0,
"cqpsk_tracking": True,
},
}
class DecodeMode(str, Enum):
P25 = "P25"
DMR = "DMR"
ANALOG = "NBFM"
class TalkgroupTag(BaseModel):
talkgroup: str
tagDec: int
# Defined before ConfigGenerator, which annotates a field with it. Under
# Python 3.14 (PEP 649) annotations are evaluated lazily, so the original
# order happened to work in the container; on 3.13 or earlier it is a hard
# NameError at import. Keep the definition above its first use so this file
# does not depend on the base image's Python version.
class IcecastConfig(BaseModel):
icecast_host: str
icecast_port: int
icecast_mountpoint: str
icecast_password: str
icecast_description: Optional[str] = "OP25"
icecast_genre: Optional[str] = "Public Safety"
class ConfigGenerator(BaseModel):
type: DecodeMode
systemName: str
channels: List[Union[int, str]]
tags: Optional[List[TalkgroupTag]]
whitelist: Optional[List[int]]
icecastConfig: Optional[IcecastConfig]
hardware_preset: str = "rtl-sdr-v3"
ppm_override: Optional[float] = None # when set, overrides the preset's default PPM
class DemodType(str, Enum):
CQPSK = "cqpsk"
FSK4 = "fsk4"
class FilterType(str, Enum):
RC = "rc"
WIDEPULSE = "widepulse"
class ChannelConfig(BaseModel):
name: str
trunking_sysname: Optional[str]
enable_analog: str
meta_stream_name: str
demod_type: DemodType
filter_type: FilterType
device: Optional[str] = "sdr"
cqpsk_tracking: Optional[bool] = None
frequency: Optional[float] = None
nbfmSquelch: Optional[float] = None
destination: Optional[str] = "udp://127.0.0.1:23456"
tracking_threshold: Optional[int] = 120
tracking_feedback: Optional[float] = 0.75
excess_bw: Optional[float] = 0.2
if_rate: Optional[int] = 24000
plot: Optional[str] = ""
symbol_rate: Optional[int] = 4800
blacklist: Optional[str] = ""
whitelist: Optional[str] = ""
class DeviceConfig(BaseModel):
args: Optional[str] = "rtl"
gains: Optional[str] = "lna:39"
gain_mode: Optional[bool] = False
name: Optional[str] = "sdr"
offset: Optional[int] = 0
ppm: Optional[float] = 0.0
rate: Optional[int] = 1920000
usable_bw_pct: Optional[float] = 0.85
tunable: Optional[bool] = True
class TrunkingChannelConfig(BaseModel):
sysname: str
control_channel_list: str
tagsFile: Optional[str] = None
whitelist: Optional[str] = None
nac: Optional[str] = ""
wacn: Optional[str] = ""
tdma_cc: Optional[bool] = False
crypt_behavior: Optional[int] = 2
class TrunkingConfig(BaseModel):
module: str
chans: List[TrunkingChannelConfig]
class MetadataStreamConfig(BaseModel):
stream_name: str = "stream_0"
meta_format_idle: str = "[idle]"
meta_format_tgid: str = "[%TGID%]"
meta_format_tag: str = "[%TGID%] %TAG%"
icecastServerAddress: str = "localhost"
icecastMountpoint: str = "NODE_ID"
icecastMountExt: str = ".xspf"
icecastPass: str = "PASSWORD"
delay: float = 0.0
class MetadataConfig(BaseModel):
module: str = "icemeta.py"
streams: List[MetadataStreamConfig]
class TerminalConfig(BaseModel):
module: Optional[str] = "terminal.py"
# Bind address comes from OP25_DEBUG_EXPOSE (config.py) — 127.0.0.1 unless
# that flag is set. See config.py for why.
terminal_type: Optional[str] = f"http:{bind_host()}:8081"
terminal_timeout: Optional[float] = 5.0
curses_plot_interval: Optional[float] = 0.2
http_plot_interval: Optional[float] = 1.0
http_plot_directory: Optional[str] = "../www/images"
tuning_step_large: Optional[int] = 1200
tuning_step_small: Optional[int] = 100
### ======================================================
# Icecast models
from pydantic import BaseModel
from typing import List, Optional, Union
from enum import Enum
from config import bind_host
# Preset device settings for common RTL-SDR hardware.
# gains: OP25 gain string passed to the device block.
# ppm: frequency offset correction (user should calibrate and override).
# cqpsk_tracking: disable for TCXO dongles (RTL-SDR v3, NESDR) once PPM is dialled in;
# leave enabled for unknown hardware as a safety net.
HARDWARE_PRESETS: dict = {
"rtl-sdr-v3": {
"gains": "lna:34",
"ppm": 0.0,
"cqpsk_tracking": False,
},
"nesdr-smart-v4": {
"gains": "lna:32",
"ppm": 0.0,
"cqpsk_tracking": False,
},
"other": {
"gains": "lna:32",
"ppm": 0.0,
"cqpsk_tracking": True,
},
}
class DecodeMode(str, Enum):
P25 = "P25"
DMR = "DMR"
ANALOG = "NBFM"
class TalkgroupTag(BaseModel):
talkgroup: str
tagDec: int
# Defined before ConfigGenerator, which annotates a field with it. Under
# Python 3.14 (PEP 649) annotations are evaluated lazily, so the original
# order happened to work in the container; on 3.13 or earlier it is a hard
# NameError at import. Keep the definition above its first use so this file
# does not depend on the base image's Python version.
class IcecastConfig(BaseModel):
icecast_host: str
icecast_port: int
icecast_mountpoint: str
icecast_password: str
icecast_description: Optional[str] = "OP25"
icecast_genre: Optional[str] = "Public Safety"
class ConfigGenerator(BaseModel):
type: DecodeMode
systemName: str
# P25: control channel list (Hz). NBFM: channels[0] is the one analog
# frequency to listen to (Hz).
channels: List[Union[int, str]]
tags: Optional[List[TalkgroupTag]] = None
whitelist: Optional[List[int]] = None
icecastConfig: Optional[IcecastConfig] = None
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).
nbfm_squelch: Optional[float] = None
class DemodType(str, Enum):
CQPSK = "cqpsk"
FSK4 = "fsk4"
class FilterType(str, Enum):
RC = "rc"
WIDEPULSE = "widepulse"
class ChannelConfig(BaseModel):
name: str
trunking_sysname: Optional[str] = None
enable_analog: str
meta_stream_name: str
demod_type: DemodType
filter_type: FilterType
device: Optional[str] = "sdr"
cqpsk_tracking: Optional[bool] = None
frequency: Optional[int] = None
# Key name op25_nbfm.py actually reads (from_dict(config, 'nbfm_squelch_threshold', -60)).
nbfm_squelch_threshold: Optional[float] = None
destination: Optional[str] = "udp://127.0.0.1:23456"
tracking_threshold: Optional[int] = 120
tracking_feedback: Optional[float] = 0.75
excess_bw: Optional[float] = 0.2
if_rate: Optional[int] = 24000
plot: Optional[str] = ""
symbol_rate: Optional[int] = 4800
blacklist: Optional[str] = ""
whitelist: Optional[str] = ""
class DeviceConfig(BaseModel):
args: Optional[str] = "rtl"
gains: Optional[str] = "lna:39"
gain_mode: Optional[bool] = False
name: Optional[str] = "sdr"
# Centre frequency (Hz). Unset for trunked P25, where tk_p25 tunes the
# device; required for a fixed analog channel (tunable=False).
frequency: Optional[int] = None
offset: Optional[int] = 0
ppm: Optional[float] = 0.0
rate: Optional[int] = 1920000
usable_bw_pct: Optional[float] = 0.85
tunable: Optional[bool] = True
class TrunkingChannelConfig(BaseModel):
sysname: str
control_channel_list: str
tagsFile: Optional[str] = None
whitelist: Optional[str] = None
nac: Optional[str] = ""
wacn: Optional[str] = ""
tdma_cc: Optional[bool] = False
crypt_behavior: Optional[int] = 2
class TrunkingConfig(BaseModel):
module: str
chans: List[TrunkingChannelConfig]
class MetadataStreamConfig(BaseModel):
stream_name: str = "stream_0"
meta_format_idle: str = "[idle]"
meta_format_tgid: str = "[%TGID%]"
meta_format_tag: str = "[%TGID%] %TAG%"
icecastServerAddress: str = "localhost"
icecastMountpoint: str = "NODE_ID"
icecastMountExt: str = ".xspf"
icecastPass: str = "PASSWORD"
delay: float = 0.0
class MetadataConfig(BaseModel):
module: str = "icemeta.py"
streams: List[MetadataStreamConfig]
class TerminalConfig(BaseModel):
module: Optional[str] = "terminal.py"
# Bind address comes from OP25_DEBUG_EXPOSE (config.py) — 127.0.0.1 unless
# that flag is set. See config.py for why.
terminal_type: Optional[str] = f"http:{bind_host()}:8081"
terminal_timeout: Optional[float] = 5.0
curses_plot_interval: Optional[float] = 0.2
http_plot_interval: Optional[float] = 1.0
http_plot_directory: Optional[str] = "../www/images"
tuning_step_large: Optional[int] = 1200
tuning_step_small: Optional[int] = 100
### ======================================================
# Icecast models
# (IcecastConfig itself is defined above ConfigGenerator, which references it.)
+277 -172
View File
@@ -1,172 +1,277 @@
from fastapi import HTTPException, APIRouter
import subprocess
import os
import re
import signal
import json
from models import ConfigGenerator, DecodeMode, ChannelConfig, DeviceConfig, TrunkingConfig, TrunkingChannelConfig, TerminalConfig, MetadataConfig, MetadataStreamConfig, HARDWARE_PRESETS
from internal.logger import create_logger
from internal.op25_config_utls import save_talkgroup_tags, save_whitelist, del_none_in_dict, get_current_system_from_config
from internal.liquidsoap_config_utils import generate_liquid_script
LOGGER = create_logger(__name__)
OP25_PATH = "/op25/op25/gr-op25_repeater/apps/"
OP25_SCRIPT = "run_multi-rx_service.sh"
_PGID_FILE = "/tmp/op25.pgid"
def _save_pgid(pgid: int) -> None:
with open(_PGID_FILE, "w") as f:
f.write(str(pgid))
def _read_pgid():
try:
return int(open(_PGID_FILE).read().strip())
except Exception:
return None
def _is_running() -> bool:
pgid = _read_pgid()
if pgid is None:
return False
try:
os.killpg(pgid, 0)
return True
except OSError:
return False
def create_op25_router():
router = APIRouter()
@router.post("/start")
async def start_op25():
if _is_running():
return {"status": "OP25 already running"}
try:
proc = subprocess.Popen(os.path.join(OP25_PATH, OP25_SCRIPT), shell=True, preexec_fn=os.setsid, cwd=OP25_PATH)
_save_pgid(proc.pid)
LOGGER.debug(proc)
return {"status": "OP25 started"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/stop")
async def stop_op25():
pgid = _read_pgid()
if pgid is None or not _is_running():
return {"status": "OP25 is not running"}
try:
os.killpg(pgid, signal.SIGTERM)
os.remove(_PGID_FILE)
return {"status": "OP25 stopped"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/status")
async def get_status():
return {"status": "running" if _is_running() else "stopped"}
@router.get("/devices")
async def list_sdr_devices():
"""Enumerate connected SDR-looking USB devices via lsusb.
Same match heuristic as install.sh's one-shot host check — good enough
to answer "is a second SDR plugged in", not a serial-level device
binding (op25's DeviceConfig.args has no serial concept yet either).
"""
devices = []
try:
out = subprocess.run(["lsusb"], capture_output=True, text=True, timeout=5).stdout
for line in out.splitlines():
if re.search(r"rtl2838|realtek.*283[28]|sdr", line, re.IGNORECASE):
devices.append(line.strip())
except Exception as e:
LOGGER.warning(f"SDR device enumeration failed: {e}")
return {"count": len(devices), "devices": devices}
@router.post("/generate-config")
async def generate_config(generator: ConfigGenerator):
try:
if generator.type == DecodeMode.P25:
preset = HARDWARE_PRESETS.get(generator.hardware_preset, HARDWARE_PRESETS["other"])
ppm = generator.ppm_override if generator.ppm_override is not None else preset["ppm"]
channels = [ChannelConfig(
name=generator.systemName,
trunking_sysname=generator.systemName,
enable_analog="off",
demod_type="cqpsk",
cqpsk_tracking=preset["cqpsk_tracking"],
filter_type="rc",
meta_stream_name="stream_0"
)]
devices = [DeviceConfig(gains=preset["gains"], ppm=ppm)]
save_talkgroup_tags(generator.tags)
save_whitelist(generator.whitelist)
has_talkgroups = bool(generator.whitelist)
trunking = TrunkingConfig(
module="tk_p25.py",
chans=[TrunkingChannelConfig(
sysname=generator.systemName,
control_channel_list=','.join(str(ch) for ch in generator.channels),
tagsFile="/configs/active.cfg.tags.tsv" if has_talkgroups else None,
whitelist="/configs/active.cfg.whitelist.tsv" if has_talkgroups else None
)]
)
metadata = MetadataConfig(
streams=[
MetadataStreamConfig(
stream_name="stream_0",
icecastServerAddress = f"{generator.icecastConfig.icecast_host}:{generator.icecastConfig.icecast_port}",
icecastMountpoint = generator.icecastConfig.icecast_mountpoint,
icecastPass = generator.icecastConfig.icecast_password
)
]
)
# Generate the op25.liq file
generate_liquid_script(generator.icecastConfig)
terminal = TerminalConfig()
config_dict = {
"channels": [channel.dict() for channel in channels],
"devices": [device.dict() for device in devices],
"trunking": trunking.dict(),
"metadata": metadata.dict(),
"terminal": terminal.dict()
}
elif generator.type == DecodeMode.ANALOG:
generator = generator.config
channels = [ChannelConfig(
channelName=generator.systemName,
enableAnalog="on",
demodType="fsk4",
frequency=generator.frequency,
filterType="widepulse",
nbfmSquelch=generator.nbfmSquelch
)]
devices = [DeviceConfig(gain="LNA:32")]
config_dict = {
"channels": [channel.dict() for channel in channels],
"devices": [device.dict() for device in devices]
}
else:
raise HTTPException(status_code=400, detail="Invalid configuration type. Must be 'p25' or 'nbfm'.")
with open('/configs/active.cfg.json', 'w') as f:
json.dump(del_none_in_dict(config_dict), f, indent=2)
return {"message": "Config exported to '/configs/active.cfg.json'"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return router
from fastapi import HTTPException, APIRouter
import subprocess
import os
import re
import signal
import sys
import json
import threading
from collections import deque
from typing import Optional
from models import ConfigGenerator, DecodeMode, ChannelConfig, DeviceConfig, TrunkingConfig, TrunkingChannelConfig, TerminalConfig, MetadataConfig, MetadataStreamConfig, HARDWARE_PRESETS
from internal.logger import create_logger
from internal.op25_config_utls import save_talkgroup_tags, save_whitelist, del_none_in_dict, get_current_system_from_config
from internal.liquidsoap_config_utils import generate_liquid_script
LOGGER = create_logger(__name__)
OP25_PATH = "/op25/op25/gr-op25_repeater/apps/"
OP25_SCRIPT = "run_multi-rx_service.sh"
_PGID_FILE = "/tmp/op25.pgid"
_ACTIVE_CFG = "/configs/active.cfg.json"
# Analog: park the dongle's centre this far from the channel so the channel
# does not sit on the RTL-SDR's DC spike. Well inside the 1.92 MS/s window.
ANALOG_CENTER_OFFSET_HZ = 250_000
# Last lines multi_rx/liquidsoap printed — the only place the reason OP25
# died (SDR busy, bad config, no lock) is visible without SSH + docker logs.
_log: deque = deque(maxlen=300)
_proc: Optional[subprocess.Popen] = None
def _save_pgid(pgid: int) -> None:
with open(_PGID_FILE, "w") as f:
f.write(str(pgid))
def _read_pgid():
try:
return int(open(_PGID_FILE).read().strip())
except Exception:
return None
def _multi_rx_alive(pgid: int) -> bool:
"""True only if a live multi_rx.py process is in OP25's process group.
node-26#12: killpg(pgid, 0) succeeds while the wrapper shell or
liquidsoap outlives a dead multi_rx (and even for a zombie leader), so
the old check reported a dead scanner as "running".
"""
try:
pids = [d for d in os.listdir("/proc") if d.isdigit()]
except OSError:
return False
for pid in pids:
try:
with open(f"/proc/{pid}/stat") as f:
fields = f.read().rsplit(")", 1)[1].split()
state, pgrp = fields[0], int(fields[2])
if pgrp != pgid or state == "Z":
continue
with open(f"/proc/{pid}/cmdline", "rb") as f:
if b"multi_rx.py" in f.read():
return True
except (OSError, IndexError, ValueError):
continue
return False
def _reap() -> None:
if _proc is not None:
_proc.poll()
def _is_running() -> bool:
pgid = _read_pgid()
if pgid is None:
return False
_reap()
return _multi_rx_alive(pgid)
def _kill_group(pgid: int) -> None:
try:
os.killpg(pgid, signal.SIGTERM)
except OSError:
pass
def _pump(proc: subprocess.Popen) -> None:
"""Mirror OP25's output to the container log and keep the tail for /log."""
for line in proc.stdout:
sys.stdout.write(line)
sys.stdout.flush()
_log.append(line.rstrip("\n"))
rc = proc.wait()
_log.append(f"[OP25 exited with code {rc}]")
def create_op25_router():
router = APIRouter()
@router.post("/start")
async def start_op25():
global _proc
if _is_running():
return {"status": "OP25 already running"}
if not os.path.exists(_ACTIVE_CFG):
raise HTTPException(
status_code=409,
detail="No OP25 config yet — choose a system or frequency first.",
)
stale = _read_pgid()
if stale is not None:
# multi_rx died but liquidsoap/the wrapper may still hold the
# Icecast mount — clear the old group before starting a new one.
_kill_group(stale)
try:
_log.clear()
proc = subprocess.Popen(
os.path.join(OP25_PATH, OP25_SCRIPT), shell=True, preexec_fn=os.setsid, cwd=OP25_PATH,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1,
)
_proc = proc
_save_pgid(proc.pid)
threading.Thread(target=_pump, args=(proc,), daemon=True).start()
LOGGER.debug(proc)
return {"status": "OP25 started"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/stop")
async def stop_op25():
pgid = _read_pgid()
if pgid is None:
return {"status": "OP25 is not running"}
try:
_kill_group(pgid)
os.remove(_PGID_FILE)
return {"status": "OP25 stopped"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/status")
async def get_status():
if _is_running():
return {"status": "running"}
# Started (pgid file present, never stopped via the API) but multi_rx
# is gone: it crashed. The tail of /log says why.
if _read_pgid() is not None:
return {"status": "crashed", "configured": os.path.exists(_ACTIVE_CFG)}
return {"status": "stopped", "configured": os.path.exists(_ACTIVE_CFG)}
@router.get("/log")
async def get_log(lines: int = 80):
lines = max(1, min(lines, _log.maxlen))
return {"lines": list(_log)[-lines:]}
@router.get("/devices")
async def list_sdr_devices():
"""Enumerate connected SDR-looking USB devices via lsusb.
Same match heuristic as install.sh's one-shot host check — good enough
to answer "is a second SDR plugged in", not a serial-level device
binding (op25's DeviceConfig.args has no serial concept yet either).
"""
devices = []
try:
out = subprocess.run(["lsusb"], capture_output=True, text=True, timeout=5).stdout
for line in out.splitlines():
if re.search(r"rtl2838|realtek.*283[28]|sdr", line, re.IGNORECASE):
devices.append(line.strip())
except Exception as e:
LOGGER.warning(f"SDR device enumeration failed: {e}")
return {"count": len(devices), "devices": devices}
@router.post("/generate-config")
async def generate_config(generator: ConfigGenerator):
try:
if generator.type == DecodeMode.P25:
preset = HARDWARE_PRESETS.get(generator.hardware_preset, HARDWARE_PRESETS["other"])
ppm = generator.ppm_override if generator.ppm_override is not None else preset["ppm"]
channels = [ChannelConfig(
name=generator.systemName,
trunking_sysname=generator.systemName,
enable_analog="off",
demod_type="cqpsk",
cqpsk_tracking=preset["cqpsk_tracking"],
filter_type="rc",
meta_stream_name="stream_0"
)]
devices = [DeviceConfig(gains=preset["gains"], ppm=ppm)]
save_talkgroup_tags(generator.tags or [])
save_whitelist(generator.whitelist)
has_talkgroups = bool(generator.whitelist)
trunking = TrunkingConfig(
module="tk_p25.py",
chans=[TrunkingChannelConfig(
sysname=generator.systemName,
control_channel_list=','.join(str(ch) for ch in generator.channels),
tagsFile="/configs/active.cfg.tags.tsv" if has_talkgroups else None,
whitelist="/configs/active.cfg.whitelist.tsv" if has_talkgroups else None
)]
)
metadata = MetadataConfig(
streams=[
MetadataStreamConfig(
stream_name="stream_0",
icecastServerAddress = f"{generator.icecastConfig.icecast_host}:{generator.icecastConfig.icecast_port}",
icecastMountpoint = generator.icecastConfig.icecast_mountpoint,
icecastPass = generator.icecastConfig.icecast_password
)
]
)
# Generate the op25.liq file
generate_liquid_script(generator.icecastConfig)
terminal = TerminalConfig()
config_dict = {
"channels": [channel.dict() for channel in channels],
"devices": [device.dict() for device in devices],
"trunking": trunking.dict(),
"metadata": metadata.dict(),
"terminal": terminal.dict()
}
elif generator.type == DecodeMode.ANALOG:
if not generator.channels:
raise HTTPException(status_code=400, detail="Analog needs a frequency.")
preset = HARDWARE_PRESETS.get(generator.hardware_preset, HARDWARE_PRESETS["other"])
ppm = generator.ppm_override if generator.ppm_override is not None else preset["ppm"]
freq = int(float(generator.channels[0]))
# One fixed conventional channel: no trunking section, so
# multi_rx adds its default tk_p25 with no chans and reports
# the channel as `conventional` on the HTTP terminal.
channels = [ChannelConfig(
name=generator.systemName,
trunking_sysname="",
enable_analog="on",
demod_type="fsk4",
filter_type="rc",
frequency=freq,
meta_stream_name="",
nbfm_squelch_threshold=generator.nbfm_squelch,
)]
devices = [DeviceConfig(
gains=preset["gains"], ppm=ppm, tunable=False,
frequency=freq + ANALOG_CENTER_OFFSET_HZ,
)]
if generator.icecastConfig:
generate_liquid_script(generator.icecastConfig)
config_dict = {
"channels": [channel.dict() for channel in channels],
"devices": [device.dict() for device in devices],
"terminal": TerminalConfig().dict(),
}
else:
raise HTTPException(status_code=400, detail="Unsupported system type. Must be 'P25' or 'NBFM' (DMR is not implemented).")
with open(_ACTIVE_CFG, 'w') as f:
json.dump(del_none_in_dict(config_dict), f, indent=2)
return {"message": f"Config exported to '{_ACTIVE_CFG}'"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return router
+10 -1
View File
@@ -19,4 +19,13 @@ LIQ_PID=$! # Store the PID of the op25.liq process
# until multi_rx.py is externally stopped (via the API).
# The trap command ensures that SIGTERM is passed to the background jobs.
trap "kill $MULTI_RX_PID $LIQ_PID" SIGTERM SIGINT
wait $MULTI_RX_PID
wait $MULTI_RX_PID
RC=$?
# multi_rx is gone (crashed, or SDR could not be opened). Take liquidsoap down
# with it so nothing in this process group outlives the decoder — a leftover
# liquidsoap kept /op25/status reporting "running" (node-26#12) and held the
# Icecast mount against the next start.
echo "multi_rx.py exited (code $RC) — stopping liquidsoap."
kill $LIQ_PID 2>/dev/null
exit $RC