mirror of
https://github.com/smittix/intercept.git
synced 2026-07-17 13:58:11 -07:00
Add graphical signal meter for APRS decoding
Backend changes (routes/aprs.py): - Remove -q h flag from direwolf to enable audio level output - Add parse_audio_level() to extract levels from direwolf output - Add rate-limiting (max 10 updates/sec, min 2-level change) - Push meter events to SSE queue as type='meter' Frontend changes: - Add signal meter widget to APRS sidebar - Horizontal bar gauge with gradient (green->cyan->yellow->red) - Numeric level display (0-100) - "BURST" indicator for levels >70 - Status text (weak/moderate/strong signal) - "No RF activity" state after 5 seconds of silence - CSS styles in static/css/modes/aprs.css Also added UK region to dropdown (same freq as Europe: 144.800) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+70
-3
@@ -50,6 +50,12 @@ aprs_station_count = 0
|
|||||||
aprs_last_packet_time = None
|
aprs_last_packet_time = None
|
||||||
aprs_stations = {} # callsign -> station data
|
aprs_stations = {} # callsign -> station data
|
||||||
|
|
||||||
|
# Meter rate limiting
|
||||||
|
_last_meter_time = 0.0
|
||||||
|
_last_meter_level = -1
|
||||||
|
METER_MIN_INTERVAL = 0.1 # Max 10 updates/sec
|
||||||
|
METER_MIN_CHANGE = 2 # Only send if level changes by at least this much
|
||||||
|
|
||||||
|
|
||||||
def find_direwolf() -> Optional[str]:
|
def find_direwolf() -> Optional[str]:
|
||||||
"""Find direwolf binary."""
|
"""Find direwolf binary."""
|
||||||
@@ -275,14 +281,64 @@ def parse_weather(data: str) -> dict:
|
|||||||
return weather
|
return weather
|
||||||
|
|
||||||
|
|
||||||
|
def parse_audio_level(line: str) -> Optional[int]:
|
||||||
|
"""Parse direwolf audio level line and return normalized level (0-100).
|
||||||
|
|
||||||
|
Direwolf outputs lines like:
|
||||||
|
Audio level = 34(18/16) [NONE] __||||||______
|
||||||
|
[0.4] Audio level = 57(34/32) [NONE] __||||||||||||______
|
||||||
|
|
||||||
|
The first number after "Audio level = " is the main level indicator.
|
||||||
|
We normalize it to 0-100 scale (direwolf typically outputs 0-100+).
|
||||||
|
"""
|
||||||
|
# Match "Audio level = NN" pattern
|
||||||
|
match = re.search(r'Audio level\s*=\s*(\d+)', line, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
raw_level = int(match.group(1))
|
||||||
|
# Normalize: direwolf levels are typically 0-100, but can go higher
|
||||||
|
# Clamp to 0-100 range
|
||||||
|
normalized = min(max(raw_level, 0), 100)
|
||||||
|
return normalized
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def should_send_meter_update(level: int) -> bool:
|
||||||
|
"""Rate-limit meter updates to avoid spamming SSE.
|
||||||
|
|
||||||
|
Only send if:
|
||||||
|
- At least METER_MIN_INTERVAL seconds have passed, OR
|
||||||
|
- Level changed by at least METER_MIN_CHANGE
|
||||||
|
"""
|
||||||
|
global _last_meter_time, _last_meter_level
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
time_ok = (now - _last_meter_time) >= METER_MIN_INTERVAL
|
||||||
|
change_ok = abs(level - _last_meter_level) >= METER_MIN_CHANGE
|
||||||
|
|
||||||
|
if time_ok or change_ok:
|
||||||
|
_last_meter_time = now
|
||||||
|
_last_meter_level = level
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None:
|
def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None:
|
||||||
"""Stream decoded APRS packets to queue.
|
"""Stream decoded APRS packets and audio level meter to queue.
|
||||||
|
|
||||||
This function reads from the decoder's stdout (text mode, line-buffered).
|
This function reads from the decoder's stdout (text mode, line-buffered).
|
||||||
The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks.
|
The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks.
|
||||||
rtl_fm's stderr is sent to DEVNULL for the same reason.
|
rtl_fm's stderr is sent to DEVNULL for the same reason.
|
||||||
|
|
||||||
|
Outputs two types of messages to the queue:
|
||||||
|
- type='aprs': Decoded APRS packets
|
||||||
|
- type='meter': Audio level meter readings (rate-limited)
|
||||||
"""
|
"""
|
||||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||||
|
global _last_meter_time, _last_meter_level
|
||||||
|
|
||||||
|
# Reset meter state
|
||||||
|
_last_meter_time = 0.0
|
||||||
|
_last_meter_level = -1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
|
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
|
||||||
@@ -293,6 +349,18 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
|||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Check for audio level line first (for signal meter)
|
||||||
|
audio_level = parse_audio_level(line)
|
||||||
|
if audio_level is not None:
|
||||||
|
if should_send_meter_update(audio_level):
|
||||||
|
meter_msg = {
|
||||||
|
'type': 'meter',
|
||||||
|
'level': audio_level,
|
||||||
|
'ts': datetime.utcnow().isoformat() + 'Z'
|
||||||
|
}
|
||||||
|
app_module.aprs_queue.put(meter_msg)
|
||||||
|
continue # Audio level lines are not packets
|
||||||
|
|
||||||
# multimon-ng prefixes decoded packets with "AFSK1200: "
|
# multimon-ng prefixes decoded packets with "AFSK1200: "
|
||||||
if line.startswith('AFSK1200:'):
|
if line.startswith('AFSK1200:'):
|
||||||
line = line[9:].strip()
|
line = line[9:].strip()
|
||||||
@@ -489,7 +557,7 @@ def start_aprs() -> Response:
|
|||||||
# -r 22050 = sample rate (must match rtl_fm -s)
|
# -r 22050 = sample rate (must match rtl_fm -s)
|
||||||
# -b 16 = 16-bit signed samples
|
# -b 16 = 16-bit signed samples
|
||||||
# -t 0 = disable text colors (for cleaner parsing)
|
# -t 0 = disable text colors (for cleaner parsing)
|
||||||
# -q h = quiet: suppress audio level heard line (keeps packet output)
|
# NOTE: We do NOT use -q h here so we get audio level lines for the signal meter
|
||||||
# - = read audio from stdin (must be last argument)
|
# - = read audio from stdin (must be last argument)
|
||||||
decoder_cmd = [
|
decoder_cmd = [
|
||||||
direwolf_path,
|
direwolf_path,
|
||||||
@@ -498,7 +566,6 @@ def start_aprs() -> Response:
|
|||||||
'-r', '22050',
|
'-r', '22050',
|
||||||
'-b', '16',
|
'-b', '16',
|
||||||
'-t', '0',
|
'-t', '0',
|
||||||
'-q', 'h',
|
|
||||||
'-'
|
'-'
|
||||||
]
|
]
|
||||||
decoder_name = 'direwolf'
|
decoder_name = 'direwolf'
|
||||||
|
|||||||
@@ -46,3 +46,86 @@
|
|||||||
.aprs-stat-label {
|
.aprs-stat-label {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Signal Meter Styles */
|
||||||
|
.aprs-signal-meter {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.aprs-meter-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.aprs-meter-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.aprs-meter-value {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
.aprs-meter-burst {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
background: rgba(255, 193, 7, 0.2);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
animation: burst-flash 0.3s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes burst-flash {
|
||||||
|
0% { opacity: 1; transform: scale(1.1); }
|
||||||
|
100% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
.aprs-meter-bar-container {
|
||||||
|
position: relative;
|
||||||
|
height: 16px;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.aprs-meter-bar {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
var(--accent-green) 0%,
|
||||||
|
var(--accent-cyan) 50%,
|
||||||
|
var(--accent-yellow) 75%,
|
||||||
|
var(--accent-red) 100%
|
||||||
|
);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.1s ease-out;
|
||||||
|
}
|
||||||
|
.aprs-meter-bar.no-signal {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
.aprs-meter-ticks {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
.aprs-meter-status {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.aprs-meter-status.active {
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
.aprs-meter-status.no-signal {
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
}
|
||||||
|
|||||||
+99
-1
@@ -5949,6 +5949,9 @@
|
|||||||
let isAprsRunning = false;
|
let isAprsRunning = false;
|
||||||
let aprsPacketCount = 0;
|
let aprsPacketCount = 0;
|
||||||
let aprsStationCount = 0;
|
let aprsStationCount = 0;
|
||||||
|
let aprsMeterLastUpdate = 0;
|
||||||
|
let aprsMeterCheckInterval = null;
|
||||||
|
const APRS_METER_TIMEOUT = 5000; // 5 seconds for "no signal" state
|
||||||
|
|
||||||
function checkAprsTools() {
|
function checkAprsTools() {
|
||||||
fetch('/aprs/tools')
|
fetch('/aprs/tools')
|
||||||
@@ -6052,6 +6055,10 @@
|
|||||||
updateAprsStatus('listening', data.frequency);
|
updateAprsStatus('listening', data.frequency);
|
||||||
document.getElementById('aprsStatusStations').textContent = '0';
|
document.getElementById('aprsStatusStations').textContent = '0';
|
||||||
document.getElementById('aprsStatusPackets').textContent = '0';
|
document.getElementById('aprsStatusPackets').textContent = '0';
|
||||||
|
// Show signal meter
|
||||||
|
document.getElementById('aprsSignalMeter').style.display = 'block';
|
||||||
|
resetAprsMeter();
|
||||||
|
startAprsMeterCheck();
|
||||||
startAprsStream();
|
startAprsStream();
|
||||||
} else {
|
} else {
|
||||||
alert('APRS Error: ' + data.message);
|
alert('APRS Error: ' + data.message);
|
||||||
@@ -6071,10 +6078,13 @@
|
|||||||
isAprsRunning = false;
|
isAprsRunning = false;
|
||||||
document.getElementById('startAprsBtn').style.display = 'block';
|
document.getElementById('startAprsBtn').style.display = 'block';
|
||||||
document.getElementById('stopAprsBtn').style.display = 'none';
|
document.getElementById('stopAprsBtn').style.display = 'none';
|
||||||
// Hide sidebar status bar
|
// Hide sidebar status bar and signal meter
|
||||||
document.getElementById('aprsStatusBar').style.display = 'none';
|
document.getElementById('aprsStatusBar').style.display = 'none';
|
||||||
|
document.getElementById('aprsSignalMeter').style.display = 'none';
|
||||||
document.getElementById('aprsMapStatus').textContent = 'STANDBY';
|
document.getElementById('aprsMapStatus').textContent = 'STANDBY';
|
||||||
document.getElementById('aprsMapStatus').style.color = '';
|
document.getElementById('aprsMapStatus').style.color = '';
|
||||||
|
// Stop meter check interval
|
||||||
|
stopAprsMeterCheck();
|
||||||
if (aprsEventSource) {
|
if (aprsEventSource) {
|
||||||
aprsEventSource.close();
|
aprsEventSource.close();
|
||||||
aprsEventSource = null;
|
aprsEventSource = null;
|
||||||
@@ -6099,6 +6109,9 @@
|
|||||||
updateAprsStatus('tracking');
|
updateAprsStatus('tracking');
|
||||||
}
|
}
|
||||||
processAprsPacket(data);
|
processAprsPacket(data);
|
||||||
|
} else if (data.type === 'meter') {
|
||||||
|
// Update signal meter
|
||||||
|
updateAprsMeter(data.level);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -6108,6 +6121,91 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Signal Meter Functions
|
||||||
|
function resetAprsMeter() {
|
||||||
|
aprsMeterLastUpdate = 0;
|
||||||
|
const bar = document.getElementById('aprsMeterBar');
|
||||||
|
const value = document.getElementById('aprsMeterValue');
|
||||||
|
const burst = document.getElementById('aprsMeterBurst');
|
||||||
|
const status = document.getElementById('aprsMeterStatus');
|
||||||
|
if (bar) {
|
||||||
|
bar.style.width = '0%';
|
||||||
|
bar.classList.remove('no-signal');
|
||||||
|
}
|
||||||
|
if (value) value.textContent = '--';
|
||||||
|
if (burst) burst.style.display = 'none';
|
||||||
|
if (status) {
|
||||||
|
status.textContent = 'Waiting for signal...';
|
||||||
|
status.className = 'aprs-meter-status';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAprsMeter(level) {
|
||||||
|
aprsMeterLastUpdate = Date.now();
|
||||||
|
const bar = document.getElementById('aprsMeterBar');
|
||||||
|
const value = document.getElementById('aprsMeterValue');
|
||||||
|
const burst = document.getElementById('aprsMeterBurst');
|
||||||
|
const status = document.getElementById('aprsMeterStatus');
|
||||||
|
|
||||||
|
if (bar) {
|
||||||
|
bar.style.width = level + '%';
|
||||||
|
bar.classList.remove('no-signal');
|
||||||
|
}
|
||||||
|
if (value) value.textContent = level;
|
||||||
|
|
||||||
|
// Show burst indicator for high levels (>70)
|
||||||
|
if (burst) {
|
||||||
|
if (level > 70) {
|
||||||
|
burst.style.display = 'inline';
|
||||||
|
// Remove and re-add to trigger animation
|
||||||
|
burst.style.animation = 'none';
|
||||||
|
burst.offsetHeight; // Trigger reflow
|
||||||
|
burst.style.animation = null;
|
||||||
|
} else {
|
||||||
|
burst.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status text
|
||||||
|
if (status) {
|
||||||
|
status.className = 'aprs-meter-status active';
|
||||||
|
if (level < 10) {
|
||||||
|
status.textContent = 'Low signal / noise floor';
|
||||||
|
} else if (level < 30) {
|
||||||
|
status.textContent = 'Weak signal detected';
|
||||||
|
} else if (level < 60) {
|
||||||
|
status.textContent = 'Moderate signal';
|
||||||
|
} else {
|
||||||
|
status.textContent = 'Strong signal / packet burst';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAprsMeterCheck() {
|
||||||
|
// Check for no-signal state every second
|
||||||
|
aprsMeterCheckInterval = setInterval(function() {
|
||||||
|
if (aprsMeterLastUpdate > 0 && (Date.now() - aprsMeterLastUpdate) > APRS_METER_TIMEOUT) {
|
||||||
|
// No meter updates for 5 seconds - show no-signal state
|
||||||
|
const bar = document.getElementById('aprsMeterBar');
|
||||||
|
const status = document.getElementById('aprsMeterStatus');
|
||||||
|
const burst = document.getElementById('aprsMeterBurst');
|
||||||
|
if (bar) bar.classList.add('no-signal');
|
||||||
|
if (burst) burst.style.display = 'none';
|
||||||
|
if (status) {
|
||||||
|
status.textContent = 'No RF activity / silence';
|
||||||
|
status.className = 'aprs-meter-status no-signal';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAprsMeterCheck() {
|
||||||
|
if (aprsMeterCheckInterval) {
|
||||||
|
clearInterval(aprsMeterCheckInterval);
|
||||||
|
aprsMeterCheckInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function processAprsPacket(packet) {
|
function processAprsPacket(packet) {
|
||||||
// Update packet log
|
// Update packet log
|
||||||
const logEl = document.getElementById('aprsPacketLog');
|
const logEl = document.getElementById('aprsPacketLog');
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<select id="aprsRegion">
|
<select id="aprsRegion">
|
||||||
<option value="north_america">North America (144.390)</option>
|
<option value="north_america">North America (144.390)</option>
|
||||||
<option value="europe">Europe (144.800)</option>
|
<option value="europe">Europe (144.800)</option>
|
||||||
|
<option value="uk">UK (144.800)</option>
|
||||||
<option value="australia">Australia (145.175)</option>
|
<option value="australia">Australia (145.175)</option>
|
||||||
<option value="japan">Japan (144.640)</option>
|
<option value="japan">Japan (144.640)</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -48,4 +49,23 @@
|
|||||||
<span class="aprs-stat"><span class="aprs-stat-label">PACKETS:</span> <span id="aprsStatusPackets">0</span></span>
|
<span class="aprs-stat"><span class="aprs-stat-label">PACKETS:</span> <span id="aprsStatusPackets">0</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Signal Meter -->
|
||||||
|
<div id="aprsSignalMeter" class="aprs-signal-meter" style="display: none;">
|
||||||
|
<div class="aprs-meter-header">
|
||||||
|
<span class="aprs-meter-label">SIGNAL</span>
|
||||||
|
<span class="aprs-meter-value" id="aprsMeterValue">--</span>
|
||||||
|
<span class="aprs-meter-burst" id="aprsMeterBurst" style="display: none;">BURST</span>
|
||||||
|
</div>
|
||||||
|
<div class="aprs-meter-bar-container">
|
||||||
|
<div class="aprs-meter-bar" id="aprsMeterBar"></div>
|
||||||
|
<div class="aprs-meter-ticks">
|
||||||
|
<span>0</span>
|
||||||
|
<span>25</span>
|
||||||
|
<span>50</span>
|
||||||
|
<span>75</span>
|
||||||
|
<span>100</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="aprs-meter-status" id="aprsMeterStatus">Waiting for signal...</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user