Files
intercept/intercept.py
James Smith 16d60d2684 Initial release of INTERCEPT - POCSAG/FLEX pager decoder
Features:
- Real-time POCSAG (512/1200/2400) and FLEX decoding
- Web-based interface with SpaceX-inspired dark theme
- Live message streaming via SSE
- Message logging to file
- Customizable frequency presets
- RTL-SDR device detection and selection
- Configurable gain, squelch, and PPM correction

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 14:02:10 +00:00

1482 lines
49 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Pager Decoder - POCSAG/FLEX decoder using RTL-SDR and multimon-ng
"""
import subprocess
import shutil
import re
import threading
import queue
import pty
import os
import select
from flask import Flask, render_template_string, jsonify, request, Response
app = Flask(__name__)
# Global process management
current_process = None
output_queue = queue.Queue()
process_lock = threading.Lock()
# Logging settings
logging_enabled = False
log_file_path = 'pager_messages.log'
HTML_TEMPLATE = '''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>INTERCEPT // Signal Intelligence</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Rajdhani:wght@400;500;600;700&display=swap');
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg-primary: #000000;
--bg-secondary: #0a0a0a;
--bg-tertiary: #111111;
--bg-card: #0d0d0d;
--accent-cyan: #00d4ff;
--accent-cyan-dim: #00d4ff40;
--accent-green: #00ff88;
--accent-red: #ff3366;
--accent-orange: #ff8800;
--text-primary: #ffffff;
--text-secondary: #888888;
--text-dim: #444444;
--border-color: #1a1a1a;
--border-glow: #00d4ff33;
}
body {
font-family: 'Rajdhani', 'Segoe UI', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
background-image:
radial-gradient(ellipse at top, #001a2c 0%, transparent 50%),
radial-gradient(ellipse at bottom, #0a0a0a 0%, var(--bg-primary) 100%);
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
header {
background: linear-gradient(180deg, var(--bg-secondary) 0%, transparent 100%);
padding: 30px 20px;
text-align: center;
border-bottom: 1px solid var(--border-color);
margin-bottom: 25px;
position: relative;
}
header::after {
content: '';
position: absolute;
bottom: -1px;
left: 50%;
transform: translateX(-50%);
width: 200px;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
}
header h1 {
color: var(--text-primary);
font-size: 2.5em;
font-weight: 700;
letter-spacing: 8px;
text-transform: uppercase;
margin-bottom: 8px;
text-shadow: 0 0 30px var(--accent-cyan-dim);
}
header p {
color: var(--text-secondary);
font-size: 14px;
letter-spacing: 3px;
text-transform: uppercase;
}
.logo {
margin-bottom: 15px;
animation: logo-pulse 3s ease-in-out infinite;
}
.logo svg {
filter: drop-shadow(0 0 10px var(--accent-cyan-dim));
}
@keyframes logo-pulse {
0%, 100% {
filter: drop-shadow(0 0 5px var(--accent-cyan-dim));
}
50% {
filter: drop-shadow(0 0 20px var(--accent-cyan));
}
}
.main-content {
display: grid;
grid-template-columns: 340px 1fr;
gap: 25px;
}
@media (max-width: 900px) {
.main-content {
grid-template-columns: 1fr;
}
}
.sidebar {
background: var(--bg-card);
border: 1px solid var(--border-color);
padding: 20px;
position: relative;
}
.sidebar::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, var(--accent-cyan), transparent);
}
.section {
margin-bottom: 25px;
}
.section h3 {
color: var(--accent-cyan);
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-color);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 3px;
display: flex;
align-items: center;
gap: 8px;
}
.section h3::before {
content: '//';
color: var(--text-dim);
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 6px;
color: var(--text-secondary);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
}
.form-group input,
.form-group select {
width: 100%;
padding: 12px 15px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
transition: all 0.2s ease;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-cyan);
box-shadow: 0 0 15px var(--accent-cyan-dim), inset 0 0 15px var(--accent-cyan-dim);
}
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
padding: 8px 12px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
transition: all 0.2s ease;
}
.checkbox-group label:hover {
border-color: var(--accent-cyan);
}
.checkbox-group input[type="checkbox"] {
width: auto;
accent-color: var(--accent-cyan);
}
.preset-buttons {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.preset-btn {
padding: 10px 16px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s ease;
}
.preset-btn:hover {
background: var(--accent-cyan);
color: var(--bg-primary);
border-color: var(--accent-cyan);
box-shadow: 0 0 20px var(--accent-cyan-dim);
}
.run-btn {
width: 100%;
padding: 16px;
background: transparent;
border: 2px solid var(--accent-green);
color: var(--accent-green);
font-family: 'Rajdhani', sans-serif;
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 4px;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 15px;
position: relative;
overflow: hidden;
}
.run-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, var(--accent-green), transparent);
opacity: 0.3;
transition: left 0.5s ease;
}
.run-btn:hover {
background: var(--accent-green);
color: var(--bg-primary);
box-shadow: 0 0 30px rgba(0, 255, 136, 0.4);
}
.run-btn:hover::before {
left: 100%;
}
.stop-btn {
width: 100%;
padding: 16px;
background: transparent;
border: 2px solid var(--accent-red);
color: var(--accent-red);
font-family: 'Rajdhani', sans-serif;
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 4px;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 15px;
}
.stop-btn:hover {
background: var(--accent-red);
color: var(--bg-primary);
box-shadow: 0 0 30px rgba(255, 51, 102, 0.4);
}
.output-panel {
background: var(--bg-card);
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
position: relative;
}
.output-panel::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
}
.output-header {
padding: 18px 25px;
background: var(--bg-secondary);
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
}
.output-header h3 {
color: var(--text-primary);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 3px;
}
.stats {
display: flex;
gap: 25px;
font-size: 11px;
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
}
.stats span {
color: var(--accent-cyan);
font-weight: 500;
}
.output-content {
flex: 1;
padding: 15px;
overflow-y: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
background: var(--bg-primary);
margin: 15px;
border: 1px solid var(--border-color);
min-height: 500px;
max-height: 600px;
}
.output-content::-webkit-scrollbar {
width: 6px;
}
.output-content::-webkit-scrollbar-track {
background: var(--bg-primary);
}
.output-content::-webkit-scrollbar-thumb {
background: var(--border-color);
}
.output-content::-webkit-scrollbar-thumb:hover {
background: var(--accent-cyan);
}
.message {
padding: 15px;
margin-bottom: 10px;
border: 1px solid var(--border-color);
border-left: 3px solid var(--accent-cyan);
background: var(--bg-secondary);
position: relative;
transition: all 0.2s ease;
}
.message:hover {
border-left-color: var(--accent-cyan);
box-shadow: 0 0 20px var(--accent-cyan-dim);
}
.message.pocsag {
border-left-color: var(--accent-cyan);
}
.message.flex {
border-left-color: var(--accent-orange);
}
.message .header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
}
.message .protocol {
color: var(--accent-cyan);
font-weight: 600;
}
.message.pocsag .protocol {
color: var(--accent-cyan);
}
.message.flex .protocol {
color: var(--accent-orange);
}
.message .address {
color: var(--accent-green);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
margin-bottom: 8px;
}
.message .content {
color: var(--text-primary);
word-wrap: break-word;
font-size: 13px;
line-height: 1.5;
}
.message .content.numeric {
font-family: 'JetBrains Mono', monospace;
font-size: 15px;
letter-spacing: 2px;
color: var(--accent-cyan);
}
.status-bar {
padding: 15px 25px;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 10px;
text-transform: uppercase;
letter-spacing: 2px;
}
.status-dot {
width: 8px;
height: 8px;
background: var(--text-dim);
position: relative;
}
.status-dot.running {
background: var(--accent-green);
box-shadow: 0 0 10px var(--accent-green);
animation: pulse-glow 2s infinite;
}
@keyframes pulse-glow {
0%, 100% {
opacity: 1;
box-shadow: 0 0 10px var(--accent-green);
}
50% {
opacity: 0.7;
box-shadow: 0 0 20px var(--accent-green), 0 0 30px var(--accent-green);
}
}
.clear-btn {
padding: 8px 16px;
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 2px;
transition: all 0.2s ease;
}
.clear-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.tool-status {
font-size: 10px;
padding: 4px 10px;
margin-left: 8px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
}
.tool-status.ok {
background: transparent;
border: 1px solid var(--accent-green);
color: var(--accent-green);
}
.tool-status.missing {
background: transparent;
border: 1px solid var(--accent-red);
color: var(--accent-red);
}
.info-text {
font-size: 10px;
color: var(--text-dim);
margin-top: 8px;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Scanline effect overlay */
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.03),
rgba(0, 0, 0, 0.03) 1px,
transparent 1px,
transparent 2px
);
z-index: 1000;
}
</style>
</head>
<body>
<header>
<div class="logo">
<svg width="50" height="50" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Outer hexagon -->
<path d="M50 5 L90 27.5 L90 72.5 L50 95 L10 72.5 L10 27.5 Z" stroke="#00d4ff" stroke-width="2" fill="none"/>
<!-- Inner signal waves -->
<path d="M30 50 Q40 35, 50 50 Q60 65, 70 50" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<path d="M35 50 Q42 40, 50 50 Q58 60, 65 50" stroke="#00ff88" stroke-width="2" fill="none" stroke-linecap="round"/>
<path d="M40 50 Q45 45, 50 50 Q55 55, 60 50" stroke="#ffffff" stroke-width="1.5" fill="none" stroke-linecap="round"/>
<!-- Center dot -->
<circle cx="50" cy="50" r="3" fill="#00d4ff"/>
<!-- Corner accents -->
<path d="M50 12 L55 17 L50 17 Z" fill="#00d4ff"/>
<path d="M50 88 L45 83 L50 83 Z" fill="#00d4ff"/>
</svg>
</div>
<h1>INTERCEPT</h1>
<p>Signal Intelligence // POCSAG & FLEX Decoder</p>
</header>
<div class="container">
<div class="main-content">
<div class="sidebar">
<div class="section">
<h3>Device</h3>
<div class="form-group">
<select id="deviceSelect">
{% if devices %}
{% for device in devices %}
<option value="{{ device.index }}">{{ device.index }}: {{ device.name }}</option>
{% endfor %}
{% else %}
<option value="0">No devices found</option>
{% endif %}
</select>
</div>
<button class="preset-btn" onclick="refreshDevices()" style="width: 100%;">
Refresh Devices
</button>
<div class="info-text">
rtl_fm: <span class="tool-status {{ 'ok' if tools.rtl_fm else 'missing' }}">{{ 'OK' if tools.rtl_fm else 'Missing' }}</span>
multimon-ng: <span class="tool-status {{ 'ok' if tools.multimon else 'missing' }}">{{ 'OK' if tools.multimon else 'Missing' }}</span>
</div>
</div>
<div class="section">
<h3>Frequency</h3>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="text" id="frequency" value="153.350" placeholder="e.g., 153.350">
</div>
<div class="preset-buttons" id="presetButtons">
<!-- Populated by JavaScript -->
</div>
<div style="margin-top: 8px; display: flex; gap: 5px;">
<input type="text" id="newPresetFreq" placeholder="New freq (MHz)" style="flex: 1; padding: 6px; background: #0f3460; border: 1px solid #1a1a2e; color: #fff; border-radius: 4px; font-size: 12px;">
<button class="preset-btn" onclick="addPreset()" style="background: #2ecc71;">Add</button>
</div>
<div style="margin-top: 5px;">
<button class="preset-btn" onclick="resetPresets()" style="font-size: 11px;">Reset to Defaults</button>
</div>
</div>
<div class="section">
<h3>Protocols</h3>
<div class="checkbox-group">
<label><input type="checkbox" id="proto_pocsag512" checked> POCSAG-512</label>
<label><input type="checkbox" id="proto_pocsag1200" checked> POCSAG-1200</label>
<label><input type="checkbox" id="proto_pocsag2400" checked> POCSAG-2400</label>
<label><input type="checkbox" id="proto_flex" checked> FLEX</label>
</div>
</div>
<div class="section">
<h3>Settings</h3>
<div class="form-group">
<label>Gain (dB, 0 = auto)</label>
<input type="text" id="gain" value="0" placeholder="0-49 or 0 for auto">
</div>
<div class="form-group">
<label>Squelch Level</label>
<input type="text" id="squelch" value="0" placeholder="0 = off">
</div>
<div class="form-group">
<label>PPM Correction</label>
<input type="text" id="ppm" value="0" placeholder="Frequency correction">
</div>
</div>
<div class="section">
<h3>Logging</h3>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="loggingEnabled" onchange="toggleLogging()">
Enable message logging
</label>
</div>
<div class="form-group">
<label>Log file path</label>
<input type="text" id="logFilePath" value="pager_messages.log" placeholder="pager_messages.log">
</div>
</div>
<button class="run-btn" id="startBtn" onclick="startDecoding()">
Start Decoding
</button>
<button class="stop-btn" id="stopBtn" onclick="stopDecoding()" style="display: none;">
Stop Decoding
</button>
<button class="preset-btn" onclick="killAll()" style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
Kill All Processes
</button>
</div>
<div class="output-panel">
<div class="output-header">
<h3>Decoded Messages</h3>
<div class="stats">
<div>Messages: <span id="msgCount">0</span></div>
<div>POCSAG: <span id="pocsagCount">0</span></div>
<div>FLEX: <span id="flexCount">0</span></div>
</div>
</div>
<div class="output-content" id="output">
<div class="placeholder" style="color: #888; text-align: center; padding: 50px;">
Configure settings and click "Start Decoding" to begin.
</div>
</div>
<div class="status-bar">
<div class="status-indicator">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Idle</span>
</div>
<button class="clear-btn" onclick="clearMessages()">Clear Messages</button>
</div>
</div>
</div>
</div>
<script>
let eventSource = null;
let isRunning = false;
let msgCount = 0;
let pocsagCount = 0;
let flexCount = 0;
let deviceList = {{ devices | tojson | safe }};
// Default presets (UK frequencies)
const defaultPresets = ['153.350', '153.025'];
// Load presets from localStorage or use defaults
function loadPresets() {
const saved = localStorage.getItem('pagerPresets');
return saved ? JSON.parse(saved) : [...defaultPresets];
}
function savePresets(presets) {
localStorage.setItem('pagerPresets', JSON.stringify(presets));
}
function renderPresets() {
const presets = loadPresets();
const container = document.getElementById('presetButtons');
container.innerHTML = presets.map(freq =>
`<button class="preset-btn" onclick="setFreq('${freq}')" oncontextmenu="removePreset('${freq}'); return false;" title="Right-click to remove">${freq}</button>`
).join('');
}
function addPreset() {
const input = document.getElementById('newPresetFreq');
const freq = input.value.trim();
if (!freq || isNaN(parseFloat(freq))) {
alert('Please enter a valid frequency');
return;
}
const presets = loadPresets();
if (!presets.includes(freq)) {
presets.push(freq);
savePresets(presets);
renderPresets();
}
input.value = '';
}
function removePreset(freq) {
if (confirm('Remove preset ' + freq + ' MHz?')) {
let presets = loadPresets();
presets = presets.filter(p => p !== freq);
savePresets(presets);
renderPresets();
}
}
function resetPresets() {
if (confirm('Reset to default presets?')) {
savePresets([...defaultPresets]);
renderPresets();
}
}
// Initialize presets on load
renderPresets();
function setFreq(freq) {
document.getElementById('frequency').value = freq;
}
function refreshDevices() {
fetch('/devices')
.then(r => r.json())
.then(devices => {
deviceList = devices;
const select = document.getElementById('deviceSelect');
if (devices.length === 0) {
select.innerHTML = '<option value="0">No devices found</option>';
} else {
select.innerHTML = devices.map(d =>
`<option value="${d.index}">${d.index}: ${d.name}</option>`
).join('');
}
});
}
function getSelectedDevice() {
return document.getElementById('deviceSelect').value;
}
function getSelectedProtocols() {
const protocols = [];
if (document.getElementById('proto_pocsag512').checked) protocols.push('POCSAG512');
if (document.getElementById('proto_pocsag1200').checked) protocols.push('POCSAG1200');
if (document.getElementById('proto_pocsag2400').checked) protocols.push('POCSAG2400');
if (document.getElementById('proto_flex').checked) protocols.push('FLEX');
return protocols;
}
function startDecoding() {
const freq = document.getElementById('frequency').value;
const gain = document.getElementById('gain').value;
const squelch = document.getElementById('squelch').value;
const ppm = document.getElementById('ppm').value;
const device = getSelectedDevice();
const protocols = getSelectedProtocols();
if (protocols.length === 0) {
alert('Please select at least one protocol');
return;
}
const config = {
frequency: freq,
gain: gain,
squelch: squelch,
ppm: ppm,
device: device,
protocols: protocols
};
fetch('/start', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(config)
}).then(r => r.json())
.then(data => {
if (data.status === 'started') {
setRunning(true);
startStream();
} else {
alert('Error: ' + data.message);
}
})
.catch(err => {
console.error('Start error:', err);
});
}
function stopDecoding() {
fetch('/stop', {method: 'POST'})
.then(r => r.json())
.then(data => {
setRunning(false);
if (eventSource) {
eventSource.close();
eventSource = null;
}
});
}
function killAll() {
fetch('/killall', {method: 'POST'})
.then(r => r.json())
.then(data => {
setRunning(false);
if (eventSource) {
eventSource.close();
eventSource = null;
}
showInfo('Killed all processes: ' + (data.processes.length ? data.processes.join(', ') : 'none running'));
});
}
function checkStatus() {
fetch('/status')
.then(r => r.json())
.then(data => {
if (data.running !== isRunning) {
setRunning(data.running);
if (data.running && !eventSource) {
startStream();
}
}
});
}
// Periodic status check every 5 seconds
setInterval(checkStatus, 5000);
function toggleLogging() {
const enabled = document.getElementById('loggingEnabled').checked;
const logFile = document.getElementById('logFilePath').value;
fetch('/logging', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled: enabled, log_file: logFile})
}).then(r => r.json())
.then(data => {
showInfo(data.logging ? 'Logging enabled: ' + data.log_file : 'Logging disabled');
});
}
function setRunning(running) {
isRunning = running;
document.getElementById('statusDot').classList.toggle('running', running);
document.getElementById('statusText').textContent = running ? 'Decoding...' : 'Idle';
document.getElementById('startBtn').style.display = running ? 'none' : 'block';
document.getElementById('stopBtn').style.display = running ? 'block' : 'none';
}
function startStream() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/stream');
eventSource.onopen = function() {
showInfo('Stream connected...');
};
eventSource.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'message') {
addMessage(data);
} else if (data.type === 'status') {
if (data.text === 'stopped') {
setRunning(false);
} else if (data.text === 'started') {
showInfo('Decoder started, waiting for signals...');
}
} else if (data.type === 'info') {
showInfo(data.text);
} else if (data.type === 'raw') {
showInfo(data.text);
}
};
eventSource.onerror = function(e) {
checkStatus();
};
}
function addMessage(msg) {
const output = document.getElementById('output');
// Remove placeholder if present
const placeholder = output.querySelector('.placeholder');
if (placeholder) {
placeholder.remove();
}
msgCount++;
document.getElementById('msgCount').textContent = msgCount;
let protoClass = '';
if (msg.protocol.includes('POCSAG')) {
pocsagCount++;
protoClass = 'pocsag';
document.getElementById('pocsagCount').textContent = pocsagCount;
} else if (msg.protocol.includes('FLEX')) {
flexCount++;
protoClass = 'flex';
document.getElementById('flexCount').textContent = flexCount;
}
const isNumeric = /^[0-9\s\-\*\#U]+$/.test(msg.message);
const msgEl = document.createElement('div');
msgEl.className = 'message ' + protoClass;
msgEl.innerHTML = `
<div class="header">
<span class="protocol">${msg.protocol}</span>
<span>${msg.timestamp}</span>
</div>
<div class="address">Address: ${msg.address}${msg.function ? ' | Func: ' + msg.function : ''}</div>
<div class="content ${isNumeric ? 'numeric' : ''}">${escapeHtml(msg.message)}</div>
`;
output.insertBefore(msgEl, output.firstChild);
// Limit messages displayed
while (output.children.length > 100) {
output.removeChild(output.lastChild);
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showInfo(text) {
const output = document.getElementById('output');
// Clear placeholder only (has the 'placeholder' class)
const placeholder = output.querySelector('.placeholder');
if (placeholder) {
placeholder.remove();
}
const infoEl = document.createElement('div');
infoEl.className = 'info-msg';
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
infoEl.textContent = text;
output.insertBefore(infoEl, output.firstChild);
}
function clearMessages() {
document.getElementById('output').innerHTML = `
<div class="placeholder" style="color: #888; text-align: center; padding: 50px;">
Messages cleared. ${isRunning ? 'Waiting for new messages...' : 'Start decoding to receive messages.'}
</div>
`;
msgCount = 0;
pocsagCount = 0;
flexCount = 0;
document.getElementById('msgCount').textContent = '0';
document.getElementById('pocsagCount').textContent = '0';
document.getElementById('flexCount').textContent = '0';
}
</script>
</body>
</html>
'''
def check_tool(name):
"""Check if a tool is installed."""
return shutil.which(name) is not None
def detect_devices():
"""Detect RTL-SDR devices."""
devices = []
if not check_tool('rtl_test'):
return devices
try:
result = subprocess.run(
['rtl_test', '-t'],
capture_output=True,
text=True,
timeout=5
)
output = result.stderr + result.stdout
# Parse device info
device_pattern = r'(\d+):\s+(.+?)(?:,\s*SN:\s*(\S+))?$'
for line in output.split('\n'):
line = line.strip()
match = re.match(device_pattern, line)
if match:
devices.append({
'index': int(match.group(1)),
'name': match.group(2).strip().rstrip(','),
'serial': match.group(3) or 'N/A'
})
if not devices:
found_match = re.search(r'Found (\d+) device', output)
if found_match:
count = int(found_match.group(1))
for i in range(count):
devices.append({
'index': i,
'name': f'RTL-SDR Device {i}',
'serial': 'Unknown'
})
except Exception:
pass
return devices
def parse_multimon_output(line):
"""Parse multimon-ng output line."""
# POCSAG formats:
# POCSAG512: Address: 1234567 Function: 0 Alpha: Message here
# POCSAG1200: Address: 1234567 Function: 0 Numeric: 123-456-7890
# POCSAG2400: Address: 1234567 Function: 0 (no message)
# FLEX formats:
# FLEX: NNNN-NN-NN NN:NN:NN NNNN/NN/C NN.NNN [NNNNNNN] ALN Message here
# FLEX|NNNN-NN-NN|NN:NN:NN|NNNN/NN/C|NN.NNN|NNNNNNN|ALN|Message
line = line.strip()
# POCSAG parsing - with message content
pocsag_match = re.match(
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(Alpha|Numeric):\s*(.*)',
line
)
if pocsag_match:
return {
'protocol': pocsag_match.group(1),
'address': pocsag_match.group(2),
'function': pocsag_match.group(3),
'msg_type': pocsag_match.group(4),
'message': pocsag_match.group(5).strip() or '[No Message]'
}
# POCSAG parsing - address only (no message content)
pocsag_addr_match = re.match(
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$',
line
)
if pocsag_addr_match:
return {
'protocol': pocsag_addr_match.group(1),
'address': pocsag_addr_match.group(2),
'function': pocsag_addr_match.group(3),
'msg_type': 'Tone',
'message': '[Tone Only]'
}
# FLEX parsing (standard format)
flex_match = re.match(
r'FLEX[:\|]\s*[\d\-]+[\s\|]+[\d:]+[\s\|]+([\d/A-Z]+)[\s\|]+([\d.]+)[\s\|]+\[?(\d+)\]?[\s\|]+(\w+)[\s\|]+(.*)',
line
)
if flex_match:
return {
'protocol': 'FLEX',
'address': flex_match.group(3),
'function': flex_match.group(1),
'msg_type': flex_match.group(4),
'message': flex_match.group(5).strip() or '[No Message]'
}
# Simple FLEX format
flex_simple = re.match(r'FLEX:\s*(.+)', line)
if flex_simple:
return {
'protocol': 'FLEX',
'address': 'Unknown',
'function': '',
'msg_type': 'Unknown',
'message': flex_simple.group(1).strip()
}
return None
def stream_decoder(master_fd, process):
"""Stream decoder output to queue using PTY for unbuffered output."""
global current_process
try:
output_queue.put({'type': 'status', 'text': 'started'})
buffer = ""
while True:
try:
ready, _, _ = select.select([master_fd], [], [], 1.0)
except Exception:
break
if ready:
try:
data = os.read(master_fd, 1024)
if not data:
break
buffer += data.decode('utf-8', errors='replace')
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if not line:
continue
parsed = parse_multimon_output(line)
if parsed:
from datetime import datetime
parsed['timestamp'] = datetime.now().strftime('%H:%M:%S')
output_queue.put({'type': 'message', **parsed})
log_message(parsed)
else:
output_queue.put({'type': 'raw', 'text': line})
except OSError:
break
if process.poll() is not None:
break
except Exception as e:
output_queue.put({'type': 'error', 'text': str(e)})
finally:
try:
os.close(master_fd)
except:
pass
process.wait()
output_queue.put({'type': 'status', 'text': 'stopped'})
with process_lock:
current_process = None
@app.route('/')
def index():
tools = {
'rtl_fm': check_tool('rtl_fm'),
'multimon': check_tool('multimon-ng')
}
devices = detect_devices()
return render_template_string(HTML_TEMPLATE, tools=tools, devices=devices)
@app.route('/devices')
def get_devices():
return jsonify(detect_devices())
@app.route('/start', methods=['POST'])
def start_decoding():
global current_process
with process_lock:
if current_process:
return jsonify({'status': 'error', 'message': 'Already running'})
data = request.json
freq = data.get('frequency', '929.6125')
gain = data.get('gain', '0')
squelch = data.get('squelch', '0')
ppm = data.get('ppm', '0')
device = data.get('device', '0')
protocols = data.get('protocols', ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX'])
# Clear queue
while not output_queue.empty():
try:
output_queue.get_nowait()
except:
break
# Build multimon-ng decoder arguments
decoders = []
for proto in protocols:
if proto == 'POCSAG512':
decoders.extend(['-a', 'POCSAG512'])
elif proto == 'POCSAG1200':
decoders.extend(['-a', 'POCSAG1200'])
elif proto == 'POCSAG2400':
decoders.extend(['-a', 'POCSAG2400'])
elif proto == 'FLEX':
decoders.extend(['-a', 'FLEX'])
# Build rtl_fm command
# rtl_fm -d <device> -f <freq>M -M fm -s 22050 -g <gain> -p <ppm> -l <squelch> - | multimon-ng -t raw -a POCSAG512 -a POCSAG1200 -a FLEX -f alpha -
rtl_cmd = [
'rtl_fm',
'-d', str(device),
'-f', f'{freq}M',
'-M', 'fm',
'-s', '22050',
]
if gain and gain != '0':
rtl_cmd.extend(['-g', str(gain)])
if ppm and ppm != '0':
rtl_cmd.extend(['-p', str(ppm)])
if squelch and squelch != '0':
rtl_cmd.extend(['-l', str(squelch)])
rtl_cmd.append('-')
multimon_cmd = ['multimon-ng', '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
# Log the command being run
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
print(f"Running: {full_cmd}")
try:
# Create pipe: rtl_fm | multimon-ng
# Use PTY for multimon-ng to get unbuffered output
rtl_process = subprocess.Popen(
rtl_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
# Start a thread to monitor rtl_fm stderr for errors
def monitor_rtl_stderr():
for line in rtl_process.stderr:
err_text = line.decode('utf-8', errors='replace').strip()
if err_text:
print(f"[RTL_FM] {err_text}", flush=True)
output_queue.put({'type': 'raw', 'text': f'[rtl_fm] {err_text}'})
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr)
rtl_stderr_thread.daemon = True
rtl_stderr_thread.start()
# Create a pseudo-terminal for multimon-ng output
# This tricks it into thinking it's connected to a terminal,
# which disables output buffering
master_fd, slave_fd = pty.openpty()
multimon_process = subprocess.Popen(
multimon_cmd,
stdin=rtl_process.stdout,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True
)
os.close(slave_fd) # Close slave fd in parent process
rtl_process.stdout.close() # Allow rtl_process to receive SIGPIPE
current_process = multimon_process
current_process._rtl_process = rtl_process # Store reference to kill later
current_process._master_fd = master_fd # Store for cleanup
# Start output thread with PTY master fd
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
thread.daemon = True
thread.start()
# Send the command info to the client
output_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError as e:
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)})
@app.route('/stop', methods=['POST'])
def stop_decoding():
global current_process
with process_lock:
if current_process:
# Kill rtl_fm process first
if hasattr(current_process, '_rtl_process'):
try:
current_process._rtl_process.terminate()
current_process._rtl_process.wait(timeout=2)
except:
try:
current_process._rtl_process.kill()
except:
pass
# Close PTY master fd
if hasattr(current_process, '_master_fd'):
try:
os.close(current_process._master_fd)
except:
pass
# Kill multimon-ng
current_process.terminate()
try:
current_process.wait(timeout=2)
except subprocess.TimeoutExpired:
current_process.kill()
current_process = None
return jsonify({'status': 'stopped'})
return jsonify({'status': 'not_running'})
@app.route('/status')
def get_status():
"""Check if decoder is currently running."""
with process_lock:
if current_process and current_process.poll() is None:
return jsonify({'running': True, 'logging': logging_enabled, 'log_file': log_file_path})
return jsonify({'running': False, 'logging': logging_enabled, 'log_file': log_file_path})
@app.route('/logging', methods=['POST'])
def toggle_logging():
"""Toggle message logging."""
global logging_enabled, log_file_path
data = request.json
if 'enabled' in data:
logging_enabled = data['enabled']
if 'log_file' in data and data['log_file']:
log_file_path = data['log_file']
return jsonify({'logging': logging_enabled, 'log_file': log_file_path})
def log_message(msg):
"""Log a message to file if logging is enabled."""
if not logging_enabled:
return
try:
with open(log_file_path, 'a') as f:
from datetime import datetime
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n")
except Exception as e:
print(f"[ERROR] Failed to log message: {e}", flush=True)
@app.route('/killall', methods=['POST'])
def kill_all():
"""Kill all rtl_fm and multimon-ng processes."""
global current_process
killed = []
try:
result = subprocess.run(['pkill', '-f', 'rtl_fm'], capture_output=True)
if result.returncode == 0:
killed.append('rtl_fm')
except:
pass
try:
result = subprocess.run(['pkill', '-f', 'multimon-ng'], capture_output=True)
if result.returncode == 0:
killed.append('multimon-ng')
except:
pass
with process_lock:
current_process = None
return jsonify({'status': 'killed', 'processes': killed})
@app.route('/stream')
def stream():
def generate():
import json
while True:
try:
msg = output_queue.get(timeout=1)
yield f"data: {json.dumps(msg)}\n\n"
except queue.Empty:
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
def main():
print("=" * 50)
print(" Pager Decoder")
print(" POCSAG / FLEX using RTL-SDR + multimon-ng")
print("=" * 50)
print()
print("Open http://localhost:5050 in your browser")
print()
print("Press Ctrl+C to stop")
print()
app.run(host='0.0.0.0', port=5050, debug=False, threaded=True)
if __name__ == '__main__':
main()