node-26#9. New secondary-sdr-container claims the node's SECOND physical SDR (RTL-SDR index 1 — op25 always claims index 0; no serial-based binding yet, same gap op25 itself has). Its control API (start/stop/status/data, mirroring op25_controller.py) launches dump1090 in adsb mode and exposes the decoded aircraft.json snapshot; AIS mode 400s until it's wired next. edge-node: on_config_push starts/stops it when secondary_sdr_mode changes, lifespan resumes it after a restart if already configured, and a new telemetry_uplink_loop polls its /secondary/data every 10s and POSTs non-empty snapshots to C2's new /telemetry/adsb (same bearer-key pattern call_recorder.py already uses for audio upload). UNVERIFIED: this container has not been built or run against real hardware in this session (sandboxed authoring machine, no docker) — dump1090's --write-json field names are believed correct from its docs but not confirmed against a real capture. Build + hardware smoke test before this reaches a real node. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
170 lines
8.5 KiB
Python
170 lines
8.5 KiB
Python
from pydantic_settings import BaseSettings
|
|
from typing import Optional
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
# Node identity
|
|
node_id: str
|
|
node_name: str = "Unnamed Node"
|
|
node_lat: float = 0.0
|
|
node_lon: float = 0.0
|
|
|
|
# MQTT
|
|
#
|
|
# Broker cutover (MQTT-PUBLIC-AUTH-PLAN.md, dynsec revision): the server no
|
|
# longer has a shared node login. Each node authenticates as
|
|
# username=NODE_ID, password=<its C2-issued api_key> (the same credential
|
|
# /upload already trusts via node_keys) — see mqtt_manager._build_client().
|
|
# For local dev against the old-style broker (localhost:1883, no TLS) set
|
|
# MQTT_BROKER=localhost and leave MQTT_TLS unset/false.
|
|
mqtt_broker: str
|
|
mqtt_port: int = 1883
|
|
# Set true for the public broker (mqtt.<domain>:8883, real Let's Encrypt
|
|
# cert) so client.tls_set() runs with default system-CA verification.
|
|
# False by default so local/dev against a plaintext :1883 broker still
|
|
# works unchanged. Do NOT pair with a self-signed/insecure cert setup —
|
|
# verification is never disabled (no tls_insecure_set(True) anywhere).
|
|
mqtt_tls: bool = False
|
|
# DEPRECATED / effectively dead post-cutover: the shared node login these
|
|
# backed no longer exists on the server (dynsec has no such client — see
|
|
# dynsec.py). Left in only as a legacy fallback for a pre-cutover broker
|
|
# that still uses mosquitto's old password_file auth; mqtt_manager only
|
|
# falls back to these when no api_key is on disk yet. Do not provision new
|
|
# nodes with these — see MQTT_USER/MQTT_PASS removal note in .env.example.
|
|
mqtt_user: Optional[str] = None
|
|
mqtt_pass: Optional[str] = None
|
|
|
|
# C2 server (audio upload destination); None disables upload
|
|
c2_url: Optional[str] = None
|
|
|
|
# Local Icecast — live listening only (frontend / mobile).
|
|
# NOT used for call recording or Discord voice: it lags 1s and drifts to 100s+.
|
|
icecast_host: str = "localhost"
|
|
icecast_port: int = 8000
|
|
icecast_mount: str = "/radio"
|
|
# No default: see icecast/entrypoint.sh, which refuses to start without one.
|
|
icecast_source_password: str = ""
|
|
|
|
# PulseAudio — the low-latency path used for call recording and Discord voice.
|
|
# Liquidsoap (op25 container) writes into the `drb_sink` null sink; we capture
|
|
# its monitor. Addressed explicitly rather than via "default" because the op25
|
|
# entrypoint starts pulseaudio with -n and never applies system.pa's
|
|
# `set-default-source` line.
|
|
pulse_source: str = "drb_sink.monitor"
|
|
# Bounded wait for the shared PulseAudio socket before launching FFmpeg.
|
|
pulse_wait_timeout: float = 30.0
|
|
|
|
# ------------------------------------------------------------------
|
|
# Call segmentation
|
|
#
|
|
# Boundaries come from the AUDIO, not the control channel. A recording
|
|
# starts at voice onset and ends after call_silence_timeout seconds of
|
|
# silence actually heard in the stream. See internal/metadata_watcher.py
|
|
# for why the control channel is no longer trusted for either edge.
|
|
# ------------------------------------------------------------------
|
|
|
|
# Seconds of continuous silence IN THE AUDIO before the current recording is
|
|
# closed. This is the primary segmentation control. Consecutive
|
|
# transmissions on the SAME talkgroup separated by less than this stay in
|
|
# one recording, so back-and-forth traffic is one file.
|
|
#
|
|
# Defaults to 3.0 to match the behaviour of the control-channel idle timer
|
|
# it replaces, but it is NOT the same clock: this one measures real silence
|
|
# in the audio, with no grant->speech delay mixed in. metadata_watcher logs
|
|
# the measured trailing silence on every close — tune from that number.
|
|
call_silence_timeout: float = 3.0
|
|
|
|
# dBFS (RMS, measured over one ~46ms capture chunk) below which audio counts
|
|
# as silence for the purpose of ending a recording.
|
|
#
|
|
# This does NOT need field calibration against radio noise. Between
|
|
# transmissions the capture is the monitor of a PulseAudio *null sink*,
|
|
# which emits digital silence, not an analog noise floor: measured on a live
|
|
# node the gap sits at about -91 dBFS, i.e. one least-significant bit of a
|
|
# 16-bit sample. Speech on the same node averages about -18 dBFS. Anything
|
|
# between roughly -70 and -40 therefore behaves identically; -50 is chosen
|
|
# to sit far below even quiet speech while staying far above the floor.
|
|
call_silence_threshold_db: float = -50.0
|
|
|
|
# DEPRECATED as a primary control — used ONLY in console fallback mode, i.e.
|
|
# when PulseAudio capture is not producing audio and there is nothing to
|
|
# segment on. Then, and only then, the old control-channel state machine
|
|
# runs and closes a segment this many seconds after the last observed
|
|
# transmission. Those segments carry no audio; they exist so the node keeps
|
|
# reporting real radio activity to C2 while its audio path is broken.
|
|
#
|
|
# Do NOT tune this against measured *audio* silence — use
|
|
# call_silence_timeout for that.
|
|
call_idle_timeout: float = 3.0
|
|
|
|
# Audio kept past a CONSOLE-DERIVED segment boundary, covering the fact that
|
|
# buffered audio lags control-channel timestamps by ~1.5s (grant->speech
|
|
# offset measured 0.84-1.62s across 7 field calls).
|
|
#
|
|
# Still needed, with a narrower job than before. It no longer pads the
|
|
# normal end of a call — that boundary now comes from the audio itself and
|
|
# needs no pad at all. It applies to the three boundaries that are still
|
|
# control-channel timestamps:
|
|
#
|
|
# tgid_change close the outgoing call at the new grant + pad
|
|
# tgid_change_unlogged close at the observing poll + pad
|
|
# idle_timeout console fallback mode only
|
|
#
|
|
# Safe to be generous: trim_silence strips trailing silence back to
|
|
# trim_silence_guard_seconds before upload, so a larger pad costs long calls
|
|
# nothing. Over-capture is free; under-capture loses words permanently. If
|
|
# the outgoing and incoming recordings overlap in the underlying audio
|
|
# because of this pad, that is correct — the audio contains both.
|
|
call_tail_pad_seconds: float = 3.0
|
|
|
|
# Strip leading/trailing dead air before upload. A recording deliberately
|
|
# over-captures at both ends (pre-roll at the head, the whole measured
|
|
# silence run at the tail), which inflates Whisper cost and is a
|
|
# well-documented trigger for hallucinated transcript text. Trimming is a
|
|
# sample-offset slice of the buffered PCM — no re-encode — and only ever
|
|
# touches the head and tail. See internal/audio_trim.py.
|
|
trim_silence: bool = True
|
|
# dBFS (RMS) below which audio counts as silence when trimming the ends.
|
|
# Kept above call_silence_threshold_db on purpose: the closer must not miss
|
|
# speech (permissive), the trimmer must not leave dead air (stricter), and
|
|
# trim_silence_guard_seconds protects the syllable either way.
|
|
trim_silence_threshold_db: float = -40.0
|
|
# Guard margin kept around detected speech so no syllable is clipped.
|
|
trim_silence_guard_seconds: float = 0.25
|
|
|
|
# OP25 container
|
|
op25_api_url: str = "http://localhost:8001"
|
|
op25_terminal_url: str = "http://localhost:8081"
|
|
|
|
# Secondary SDR container (node-26#9) — ADS-B / AIS on a second SDR
|
|
secondary_sdr_api_url: str = "http://localhost:8002"
|
|
|
|
# Paths (volume mounts)
|
|
config_path: str = "/configs"
|
|
recordings_path: str = "/recordings"
|
|
|
|
# Offline call buffer — how many call_end events to keep while disconnected
|
|
offline_call_buffer_size: int = 35
|
|
|
|
# ------------------------------------------------------------------
|
|
# Local dashboard / API authentication
|
|
#
|
|
# These nodes are deployed at arbitrary third-party locations, reachable by
|
|
# anyone on that site's LAN — there is no auth on this HTTP surface without
|
|
# these. The password below is a FIRST-BOOT DEFAULT ONLY: change it via
|
|
# DASHBOARD_PASSWORD in .env before a node leaves the bench. main.py logs a
|
|
# startup warning every boot the default is still active.
|
|
#
|
|
# See app/internal/auth.py — the password is never compared or stored in
|
|
# plaintext (scrypt-hashed, constant-time compare); this setting just holds
|
|
# the operator-facing plaintext the same way MQTT_PASS/ICECAST_* already do.
|
|
# ------------------------------------------------------------------
|
|
dashboard_username: str = "admin"
|
|
dashboard_password: str = "CHANGE-ME-drb-default"
|
|
|
|
class Config:
|
|
env_file = ".env"
|
|
|
|
|
|
settings = Settings()
|