This commit is contained in:
Smittix
2026-01-20 18:07:40 +00:00
13 changed files with 791 additions and 8 deletions

View File

@@ -61,7 +61,9 @@ docker compose up -d
### Open the Interface
After starting, open **http://localhost:5050** in your browser. The username and password is admin:admin
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
The credentials can be change in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
---

8
app.py
View File

@@ -134,6 +134,11 @@ aprs_rtl_process = None
aprs_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
aprs_lock = threading.Lock()
# RTLAMR utility meter reading
rtlamr_process = None
rtlamr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
rtlamr_lock = threading.Lock()
# TSCM (Technical Surveillance Countermeasures)
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock()
@@ -225,7 +230,8 @@ def index() -> str:
tools = {
'rtl_fm': check_tool('rtl_fm'),
'multimon': check_tool('multimon-ng'),
'rtl_433': check_tool('rtl_433')
'rtl_433': check_tool('rtl_433'),
'rtlamr': check_tool('rtlamr')
}
devices = [d.to_dict() for d in SDRFactory.detect_devices()]
return render_template('index.html', tools=tools, devices=devices, version=VERSION, changelog=CHANGELOG)

View File

@@ -31,6 +31,9 @@ dependencies = [
"pyserial>=3.5",
"Werkzeug>=3.1.5",
"flask-limiter>=2.5.4",
"bleak>=0.21.0",
"flask-sock",
"requests>=2.28.0",
]
[project.urls]

View File

@@ -1,6 +1,8 @@
# Core dependencies
flask>=2.0.0
flask-limiter>=2.5.4
requests>=2.28.0
Werkzeug>=3.1.5
# BLE scanning with manufacturer data detection (optional - for TSCM)
bleak>=0.21.0

View File

@@ -4,6 +4,7 @@ def register_blueprints(app):
"""Register all route blueprints with the Flask app."""
from .pager import pager_bp
from .sensor import sensor_bp
from .rtlamr import rtlamr_bp
from .wifi import wifi_bp
from .bluetooth import bluetooth_bp
from .adsb import adsb_bp
@@ -18,6 +19,7 @@ def register_blueprints(app):
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
app.register_blueprint(rtlamr_bp)
app.register_blueprint(wifi_bp)
app.register_blueprint(bluetooth_bp)
app.register_blueprint(adsb_bp)

250
routes/rtlamr.py Normal file
View File

@@ -0,0 +1,250 @@
"""RTLAMR utility meter monitoring routes."""
from __future__ import annotations
import json
import queue
import subprocess
import threading
import time
from datetime import datetime
from typing import Generator
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm
)
from utils.sse import format_sse
from utils.process import safe_terminate, register_process
rtlamr_bp = Blueprint('rtlamr', __name__)
# Store rtl_tcp process separately
rtl_tcp_process = None
rtl_tcp_lock = threading.Lock()
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
"""Stream rtlamr JSON output to queue."""
try:
app_module.rtlamr_queue.put({'type': 'status', 'text': 'started'})
for line in iter(process.stdout.readline, b''):
line = line.decode('utf-8', errors='replace').strip()
if not line:
continue
try:
# rtlamr outputs JSON objects, one per line
data = json.loads(line)
data['type'] = 'rtlamr'
app_module.rtlamr_queue.put(data)
# Log if enabled
if app_module.logging_enabled:
try:
with open(app_module.log_file_path, 'a') as f:
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{timestamp} | RTLAMR | {json.dumps(data)}\n")
except Exception:
pass
except json.JSONDecodeError:
# Not JSON, send as raw
app_module.rtlamr_queue.put({'type': 'raw', 'text': line})
except Exception as e:
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
finally:
process.wait()
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.rtlamr_lock:
app_module.rtlamr_process = None
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
def start_rtlamr() -> Response:
global rtl_tcp_process
with app_module.rtlamr_lock:
if app_module.rtlamr_process:
return jsonify({'status': 'error', 'message': 'RTLAMR already running'}), 409
data = request.json or {}
# Validate inputs
try:
freq = validate_frequency(data.get('frequency', '912.0'))
gain = validate_gain(data.get('gain', '0'))
ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Clear queue
while not app_module.rtlamr_queue.empty():
try:
app_module.rtlamr_queue.get_nowait()
except queue.Empty:
break
# Get message type (default to scm)
msgtype = data.get('msgtype', 'scm')
output_format = data.get('format', 'json')
# Start rtl_tcp first
with rtl_tcp_lock:
if not rtl_tcp_process:
logger.info("Starting rtl_tcp server...")
try:
rtl_tcp_cmd = ['rtl_tcp', '-a', '0.0.0.0']
# Add device index if not 0
if device and device != '0':
rtl_tcp_cmd.extend(['-d', str(device)])
# Add gain if not auto
if gain and gain != '0':
rtl_tcp_cmd.extend(['-g', str(gain)])
# Add PPM correction if not 0
if ppm and ppm != '0':
rtl_tcp_cmd.extend(['-p', str(ppm)])
rtl_tcp_process = subprocess.Popen(
rtl_tcp_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
# Wait a moment for rtl_tcp to start
time.sleep(1)
logger.info(f"rtl_tcp started: {' '.join(rtl_tcp_cmd)}")
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
except Exception as e:
logger.error(f"Failed to start rtl_tcp: {e}")
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
# Build rtlamr command
cmd = [
'rtlamr',
'-server=127.0.0.1:1234',
f'-msgtype={msgtype}',
f'-format={output_format}',
f'-centerfreq={int(float(freq) * 1e6)}'
]
# Add filter options if provided
filterid = data.get('filterid')
if filterid:
cmd.append(f'-filterid={filterid}')
filtertype = data.get('filtertype')
if filtertype:
cmd.append(f'-filtertype={filtertype}')
# Unique messages only
if data.get('unique', True):
cmd.append('-unique=true')
full_cmd = ' '.join(cmd)
logger.info(f"Running: {full_cmd}")
try:
app_module.rtlamr_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
# Start output thread
thread = threading.Thread(target=stream_rtlamr_output, args=(app_module.rtlamr_process,))
thread.daemon = True
thread.start()
# Monitor stderr
def monitor_stderr():
for line in app_module.rtlamr_process.stderr:
err = line.decode('utf-8', errors='replace').strip()
if err:
logger.debug(f"[rtlamr] {err}")
app_module.rtlamr_queue.put({'type': 'info', 'text': f'[rtlamr] {err}'})
stderr_thread = threading.Thread(target=monitor_stderr)
stderr_thread.daemon = True
stderr_thread.start()
app_module.rtlamr_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError:
# If rtlamr fails, clean up rtl_tcp
with rtl_tcp_lock:
if rtl_tcp_process:
rtl_tcp_process.terminate()
rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
except Exception as e:
# If rtlamr fails, clean up rtl_tcp
with rtl_tcp_lock:
if rtl_tcp_process:
rtl_tcp_process.terminate()
rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None
return jsonify({'status': 'error', 'message': str(e)})
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
def stop_rtlamr() -> Response:
global rtl_tcp_process
with app_module.rtlamr_lock:
if app_module.rtlamr_process:
app_module.rtlamr_process.terminate()
try:
app_module.rtlamr_process.wait(timeout=2)
except subprocess.TimeoutExpired:
app_module.rtlamr_process.kill()
app_module.rtlamr_process = None
# Also stop rtl_tcp
with rtl_tcp_lock:
if rtl_tcp_process:
rtl_tcp_process.terminate()
try:
rtl_tcp_process.wait(timeout=2)
except subprocess.TimeoutExpired:
rtl_tcp_process.kill()
rtl_tcp_process = None
logger.info("rtl_tcp stopped")
return jsonify({'status': 'stopped'})
@rtlamr_bp.route('/stream_rtlamr')
def stream_rtlamr() -> Response:
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
msg = app_module.rtlamr_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
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

View File

@@ -178,6 +178,17 @@ check_required() {
fi
}
check_optional() {
local label="$1"; shift
local desc="$1"; shift
if have_any "$@"; then
ok "${label} - ${desc}"
else
warn "${label} - ${desc} (missing, optional)"
fi
}
check_tools() {
info "Checking required tools..."
missing_required=()
@@ -186,8 +197,10 @@ check_tools() {
info "Core SDR:"
check_required "rtl_fm" "RTL-SDR FM demodulator" rtl_fm
check_required "rtl_test" "RTL-SDR device detection" rtl_test
check_required "rtl_tcp" "RTL-SDR TCP server" rtl_tcp
check_required "multimon-ng" "Pager decoder" multimon-ng
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
check_optional "rtlamr" "Utility meter decoder (requires Go)" rtlamr
check_required "dump1090" "ADS-B decoder" dump1090
check_required "acarsdec" "ACARS decoder" acarsdec
@@ -280,9 +293,9 @@ install_python_deps() {
if ! python -m pip install -r requirements.txt 2>/dev/null; then
warn "Some pip packages failed - checking if apt packages cover them..."
# Verify critical packages are available
python -c "import flask; import requests" 2>/dev/null || {
fail "Critical Python packages (flask, requests) not installed"
echo "Try: sudo apt install python3-flask python3-requests"
python -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
fail "Critical Python packages (flask, requests, flask-limiter) not installed"
echo "Try: pip install flask requests flask-limiter"
exit 1
}
ok "Core Python dependencies available"
@@ -324,6 +337,49 @@ brew_install() {
fi
}
install_rtlamr_from_source() {
info "Installing rtlamr from source (requires Go)..."
# Check if Go is installed, install if needed
if ! cmd_exists go; then
if [[ "$OS" == "macos" ]]; then
info "Installing Go via Homebrew..."
brew_install go || { warn "Failed to install Go. Cannot install rtlamr."; return 1; }
else
info "Installing Go via apt..."
$SUDO apt-get install -y golang >/dev/null 2>&1 || { warn "Failed to install Go. Cannot install rtlamr."; return 1; }
fi
fi
# Set up Go environment
export GOPATH="${GOPATH:-$HOME/go}"
export PATH="$GOPATH/bin:$PATH"
mkdir -p "$GOPATH/bin"
info "Building rtlamr..."
if go install github.com/bemasher/rtlamr@latest 2>/dev/null; then
# Link to system path
if [[ -f "$GOPATH/bin/rtlamr" ]]; then
if [[ "$OS" == "macos" ]]; then
if [[ -w /usr/local/bin ]]; then
ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
else
sudo ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
fi
else
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
fi
ok "rtlamr installed successfully"
else
warn "rtlamr binary not found after build"
return 1
fi
else
warn "Failed to build rtlamr"
return 1
fi
}
install_multimon_ng_from_source_macos() {
info "multimon-ng not available via Homebrew. Building from source..."
@@ -382,6 +438,21 @@ install_macos_packages() {
progress "Installing rtl_433"
brew_install rtl_433
progress "Installing rtlamr (optional)"
# rtlamr is optional - used for utility meter monitoring
if ! cmd_exists rtlamr; then
echo
info "rtlamr is used for utility meter monitoring (electric/gas/water meters)."
read -r -p "Do you want to install rtlamr? [y/N] " install_rtlamr
if [[ "$install_rtlamr" =~ ^[Yy]$ ]]; then
install_rtlamr_from_source
else
warn "Skipping rtlamr installation. You can install it later if needed."
fi
else
ok "rtlamr already installed"
fi
progress "Installing dump1090"
(brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew"
@@ -686,6 +757,21 @@ install_debian_packages() {
progress "Installing rtl_433"
apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available"
progress "Installing rtlamr (optional)"
# rtlamr is optional - used for utility meter monitoring
if ! cmd_exists rtlamr; then
echo
info "rtlamr is used for utility meter monitoring (electric/gas/water meters)."
read -r -p "Do you want to install rtlamr? [y/N] " install_rtlamr
if [[ "$install_rtlamr" =~ ^[Yy]$ ]]; then
install_rtlamr_from_source
else
warn "Skipping rtlamr installation. You can install it later if needed."
fi
else
ok "rtlamr already installed"
fi
progress "Installing aircrack-ng"
apt_install aircrack-ng || true

View File

@@ -207,12 +207,16 @@ body {
background: var(--bg-panel);
border-right: 1px solid var(--border-color);
flex-direction: row;
overflow: hidden;
height: 100%;
min-height: 0;
}
/* Show ACARS sidebar on desktop */
@media (min-width: 1024px) {
.acars-sidebar {
display: flex;
max-height: calc(100dvh - 95px);
}
}
@@ -264,11 +268,13 @@ body {
}
.acars-sidebar-content {
width: 250px;
width: 300px;
display: flex;
flex-direction: column;
overflow: hidden;
transition: width 0.3s ease, opacity 0.2s ease;
height: 100%;
min-height: 0;
}
.acars-sidebar.collapsed .acars-sidebar-content {
@@ -283,12 +289,31 @@ body {
flex-direction: column;
border: none;
border-radius: 0;
min-height: 0;
overflow: hidden;
}
.acars-sidebar .panel::before {
display: none;
}
.acars-sidebar .panel-header {
flex-shrink: 0;
}
.acars-sidebar #acarsPanelContent {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.acars-sidebar .acars-info,
.acars-sidebar .acars-controls {
flex-shrink: 0;
}
.acars-sidebar .acars-messages {
flex: 1;
overflow-y: auto;

34
static/js/core/login.js Normal file
View File

@@ -0,0 +1,34 @@
/**
* Handles the visual transition and submission lock for the authorization terminal.
* @param {Event} event - The click event from the submission button.
*/
function login(event) {
const btn = event.currentTarget;
const form = btn.closest('form');
// Validate form requirements before triggering visual effects
if (!form.checkValidity()) {
return; // Allow the browser to handle native "required" field alerts
}
// 1. Visual Feedback: Transition to "Processing" state
btn.style.color = "#ff4d4d";
btn.style.borderColor = "#ff4d4d";
btn.style.textShadow = "0 0 10px #ff4d4d";
btn.style.transform = "scale(0.95)";
// Update button text to reflect terminal status
const btnText = btn.querySelector('.btn-text');
if (btnText) {
btnText.innerText = "AUTHORIZING...";
}
// 2. Security Lock: Prevent redundant requests (Double-click spam)
// A 10ms delay ensures the browser successfully dispatches the POST request
// before the UI element becomes non-interactive.
setTimeout(() => {
btn.style.pointerEvents = "none";
btn.style.opacity = "0.7";
btn.style.cursor = "not-allowed";
}, 10);
}

View File

@@ -375,6 +375,8 @@
class="nav-label">Pager</span></button>
<button class="mode-nav-btn" onclick="switchMode('sensor')"><span class="nav-icon">📡</span><span
class="nav-label">433MHz</span></button>
<button class="mode-nav-btn" onclick="switchMode('rtlamr')"><span class="nav-icon"></span><span
class="nav-label">Meters</span></button>
<a href="/adsb/dashboard" class="mode-nav-btn" style="text-decoration: none;"><span
class="nav-icon">✈️</span><span class="nav-label">Aircraft</span></a>
<button class="mode-nav-btn" onclick="switchMode('aprs')"><span class="nav-icon">📍</span><span
@@ -440,6 +442,7 @@
<nav class="mobile-nav-bar" id="mobileNavBar">
<button class="mobile-nav-btn active" data-mode="pager" onclick="switchMode('pager')">📟 Pager</button>
<button class="mobile-nav-btn" data-mode="sensor" onclick="switchMode('sensor')">📡 433MHz</button>
<button class="mobile-nav-btn" data-mode="rtlamr" onclick="switchMode('rtlamr')">⚡ Meters</button>
<a href="/adsb/dashboard" class="mobile-nav-btn" style="text-decoration: none;">✈️ Aircraft</a>
<button class="mobile-nav-btn" data-mode="aprs" onclick="switchMode('aprs')">📍 APRS</button>
<button class="mobile-nav-btn" data-mode="wifi" onclick="switchMode('wifi')">📶 WiFi</button>
@@ -543,6 +546,8 @@
{% include 'partials/modes/sensor.html' %}
{% include 'partials/modes/rtlamr.html' %}
{% include 'partials/modes/wifi.html' %}
{% include 'partials/modes/bluetooth.html' %}
@@ -1974,6 +1979,7 @@
});
document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
document.getElementById('rtlamrMode').classList.toggle('active', mode === 'rtlamr');
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
@@ -2000,6 +2006,7 @@
const modeNames = {
'pager': 'PAGER',
'sensor': '433MHZ',
'rtlamr': 'METERS',
'satellite': 'SATELLITE',
'wifi': 'WIFI',
'bluetooth': 'BLUETOOTH',
@@ -2019,6 +2026,7 @@
const titles = {
'pager': 'Pager Decoder',
'sensor': '433MHz Sensor Monitor',
'rtlamr': 'Utility Meter Monitor',
'satellite': 'Satellite Monitor',
'wifi': 'WiFi Scanner',
'bluetooth': 'Bluetooth Scanner',
@@ -2051,7 +2059,7 @@
}
// Show RTL-SDR device section for modes that use it
document.getElementById('rtlDeviceSection').style.display = (mode === 'pager' || mode === 'sensor' || mode === 'listening' || mode === 'aprs') ? 'block' : 'none';
document.getElementById('rtlDeviceSection').style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs') ? 'block' : 'none';
// Toggle mode-specific tool status displays
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
@@ -2276,6 +2284,183 @@
});
}
// ========================================
// RTLAMR Functions
// ========================================
let isRtlamrRunning = false;
function setRtlamrFreq(freq) {
document.getElementById('rtlamrFrequency').value = freq;
}
function startRtlamrDecoding() {
const freq = document.getElementById('rtlamrFrequency').value;
const gain = document.getElementById('rtlamrGain').value;
const ppm = document.getElementById('rtlamrPpm').value;
const device = getSelectedDevice();
const msgtype = document.getElementById('rtlamrMsgType').value;
const filterid = document.getElementById('rtlamrFilterId').value;
const unique = document.getElementById('rtlamrUnique').checked;
// Check if device is available
if (!checkDeviceAvailability('rtlamr')) {
return;
}
const config = {
frequency: freq,
gain: gain,
ppm: ppm,
device: device,
msgtype: msgtype,
filterid: filterid,
unique: unique,
format: 'json'
};
fetch('/start_rtlamr', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
}).then(r => r.json())
.then(data => {
if (data.status === 'started') {
reserveDevice(parseInt(device), 'rtlamr');
setRtlamrRunning(true);
startRtlamrStream();
} else {
alert('Error: ' + data.message);
}
});
}
function stopRtlamrDecoding() {
fetch('/stop_rtlamr', { method: 'POST' })
.then(r => r.json())
.then(data => {
releaseDevice('rtlamr');
setRtlamrRunning(false);
if (eventSource) {
eventSource.close();
eventSource = null;
}
});
}
function setRtlamrRunning(running) {
isRtlamrRunning = running;
document.getElementById('statusDot').classList.toggle('running', running);
document.getElementById('statusText').textContent = running ? 'Listening...' : 'Idle';
document.getElementById('startRtlamrBtn').style.display = running ? 'none' : 'block';
document.getElementById('stopRtlamrBtn').style.display = running ? 'block' : 'none';
// Update mode indicator with frequency
if (running) {
const freq = document.getElementById('rtlamrFrequency').value;
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>METERS @ ' + freq + ' MHz';
} else {
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>METERS';
}
}
function startRtlamrStream() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/stream_rtlamr');
eventSource.onopen = function () {
showInfo('RTLAMR stream connected...');
};
eventSource.onmessage = function (e) {
const data = JSON.parse(e.data);
if (data.type === 'rtlamr') {
addRtlamrReading(data);
} else if (data.type === 'status') {
if (data.text === 'stopped') {
setRtlamrRunning(false);
}
} else if (data.type === 'info' || data.type === 'raw') {
showInfo(data.text);
}
};
eventSource.onerror = function (e) {
console.error('RTLAMR stream error');
};
}
function addRtlamrReading(data) {
const output = document.getElementById('output');
const placeholder = output.querySelector('.placeholder');
if (placeholder) placeholder.remove();
// Store for export
allMessages.push(data);
playAlert();
pulseSignal();
sensorCount++;
document.getElementById('sensorCount').textContent = sensorCount;
// Track unique meters by ID
const meterId = data.Message?.ID || 'Unknown';
if (meterId !== 'Unknown') {
const deviceKey = 'METER_' + meterId;
if (!uniqueDevices.has(deviceKey)) {
uniqueDevices.add(deviceKey);
document.getElementById('deviceCount').textContent = uniqueDevices.size;
}
}
const card = document.createElement('div');
card.className = 'sensor-card';
let dataItems = '';
const msg = data.Message || {};
// Build display from message data
for (const [key, value] of Object.entries(msg)) {
if (value !== null && value !== undefined) {
const label = key.replace(/_/g, ' ');
let displayValue = value;
if (key === 'Consumption') displayValue = value + ' units';
dataItems += `<div class="sensor-item"><span class="sensor-label">${label}:</span> <span class="sensor-value">${displayValue}</span></div>`;
}
}
const timestamp = new Date().toLocaleTimeString();
card.innerHTML = `
<div class="sensor-header">
<span class="sensor-model">${data.Type || 'Meter'}</span>
<span class="sensor-time">${timestamp}</span>
</div>
<div class="sensor-data">${dataItems}</div>
`;
output.insertBefore(card, output.firstChild);
// Limit output to 50 cards
while (output.children.length > 50) {
output.removeChild(output.lastChild);
}
}
function toggleRtlamrUnique() {
// No action needed, value is read on start
}
function toggleRtlamrLogging() {
const enabled = document.getElementById('rtlamrLogging').checked;
fetch('/logging', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: enabled, log_file: 'rtlamr_data.log' })
});
}
// NOTE: Audio alert settings moved to static/js/core/audio.js
// Message storage for export
@@ -10066,6 +10251,8 @@
decoder</span></div>
<div class="icon-item"><span class="icon">📡</span><span class="desc">433MHz - Sensor decoder</span>
</div>
<div class="icon-item"><span class="icon"></span><span class="desc">Meters - Utility meter decoder</span>
</div>
<div class="icon-item"><span class="icon">✈️</span><span class="desc">Aircraft - Opens ADS-B
Dashboard</span></div>
<div class="icon-item"><span class="icon">📍</span><span class="desc">APRS - Amateur radio
@@ -10101,6 +10288,14 @@
<li>Device intelligence builds profiles of recurring devices</li>
</ul>
<h3>⚡ Utility Meter Mode</h3>
<ul class="tip-list">
<li>Decodes utility meter transmissions (water, gas, electric) using rtlamr</li>
<li>Supports ERT protocol on 912 MHz (North America) or 868 MHz (Europe)</li>
<li>Displays meter IDs and consumption data in real-time</li>
<li>Supports SCM, SCM+, IDM, NetIDM, and R900 message types</li>
</ul>
<h3>✈️ Aircraft (Dashboard)</h3>
<ul class="tip-list">
<li>Opens the dedicated ADS-B Dashboard for aircraft tracking</li>
@@ -10233,6 +10428,7 @@
<ul class="tip-list">
<li><strong>Pager:</strong> RTL-SDR, rtl_fm, multimon-ng</li>
<li><strong>433MHz Sensors:</strong> RTL-SDR, rtl_433</li>
<li><strong>Utility Meters:</strong> RTL-SDR, rtl_tcp, rtlamr</li>
<li><strong>Aircraft (ADS-B):</strong> RTL-SDR, dump1090 or rtl_adsb</li>
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>iNTERCEPT // Restricted Access</title>
<script src="{{ url_for('static', filename='js/core/login.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}" />
</head>
@@ -48,7 +49,7 @@
<input type="text" name="username" placeholder="OPERATOR ID" class="form-input" required autofocus autocomplete="off" />
<input type="password" name="password" placeholder="ENCRYPTION KEY" class="form-input" required />
<button type="submit" class="landing-enter-btn">
<button type="submit" class="landing-enter-btn" onclick="login(event)">
<span class="btn-text">INITIALIZE SESSION</span>
</button>
</form>

View File

@@ -0,0 +1,67 @@
<!-- RTLAMR UTILITY METER MODE -->
<div id="rtlamrMode" class="mode-content">
<div class="section">
<h3>Frequency</h3>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="text" id="rtlamrFrequency" value="912.0" placeholder="e.g., 912.0">
</div>
<div class="preset-buttons">
<button class="preset-btn" onclick="setRtlamrFreq('912.0')">912.0 (NA)</button>
<button class="preset-btn" onclick="setRtlamrFreq('868.0')">868.0 (EU)</button>
<button class="preset-btn" onclick="setRtlamrFreq('915.0')">915.0</button>
</div>
</div>
<div class="section">
<h3>Settings</h3>
<div class="form-group">
<label>Gain (dB, 0 = auto)</label>
<input type="text" id="rtlamrGain" value="0" placeholder="0-49 or 0 for auto">
</div>
<div class="form-group">
<label>PPM Correction</label>
<input type="text" id="rtlamrPpm" value="0" placeholder="Frequency correction">
</div>
<div class="form-group">
<label>Message Type</label>
<select id="rtlamrMsgType">
<option value="scm">SCM (Standard Consumption Message)</option>
<option value="scm+">SCM+ (Enhanced)</option>
<option value="idm">IDM (Interval Data Message)</option>
<option value="netidm">NetIDM (Network IDM)</option>
<option value="r900">R900 (Neptune)</option>
<option value="r900bcd">R900 BCD</option>
<option value="all">All Types</option>
</select>
</div>
<div class="form-group">
<label>Filter by Meter ID (optional, comma-separated)</label>
<input type="text" id="rtlamrFilterId" placeholder="e.g., 12345678,87654321">
</div>
</div>
<div class="section">
<h3>Protocols</h3>
<div class="info-text" style="margin-bottom: 10px;">
rtlamr decodes utility meter transmissions (water, gas, electric) using ERT protocol.
</div>
<div class="checkbox-group">
<label>
<input type="checkbox" id="rtlamrUnique" checked onchange="toggleRtlamrUnique()">
Unique Messages Only
</label>
<label>
<input type="checkbox" id="rtlamrLogging" onchange="toggleRtlamrLogging()">
Enable Logging
</label>
</div>
</div>
<button class="run-btn" id="startRtlamrBtn" onclick="startRtlamrDecoding()">
Start Listening
</button>
<button class="stop-btn" id="stopRtlamrBtn" onclick="stopRtlamrDecoding()" style="display: none;">
Stop Listening
</button>
</div>

109
tests/test_requirements.py Normal file
View File

@@ -0,0 +1,109 @@
import pytest
from pathlib import Path
import importlib.metadata
import tomllib # Standard in Python 3.11+
def get_root_path():
return Path(__file__).parent.parent
def _clean_string(req):
"""Normalizes a requirement string (lowercase and removes spaces)."""
return req.strip().lower().replace(" ", "")
def parse_txt_requirements(file_path):
"""Extracts full requirement strings (name + version) from a .txt file."""
if not file_path.exists():
return set()
packages = set()
with open(file_path, "r") as f:
for line in f:
line = line.strip()
# Ignore empty lines, comments, and recursive/local flags
if not line or line.startswith(("#", "-e", "git+", "-r")):
continue
packages.add(_clean_string(line))
return packages
def parse_toml_section(data, section_type="main"):
"""Extracts full requirement strings from pyproject.toml."""
packages = set()
if section_type == "main":
deps = data.get("project", {}).get("dependencies", [])
else:
# Check optional-dependencies or dependency-groups
deps = data.get("project", {}).get("optional-dependencies", {}).get("dev", [])
if not deps:
deps = data.get("dependency-groups", {}).get("dev", [])
for req in deps:
packages.add(_clean_string(req))
return packages
def test_dependency_files_integrity():
"""1. Verifies that .txt files and pyproject.toml have identical names AND versions."""
root = get_root_path()
toml_path = root / "pyproject.toml"
assert toml_path.exists(), "Missing pyproject.toml"
with open(toml_path, "rb") as f:
toml_data = tomllib.load(f)
# Validate Production Sync
txt_main = parse_txt_requirements(root / "requirements.txt")
toml_main = parse_toml_section(toml_data, "main")
assert txt_main == toml_main, (
f"Production version mismatch!\n"
f"Only in TXT: {txt_main - toml_main}\n"
f"Only in TOML: {toml_main - txt_main}"
)
# Validate Development Sync
txt_dev = parse_txt_requirements(root / "requirements-dev.txt")
toml_dev = parse_toml_section(toml_data, "dev")
assert txt_dev == toml_dev, (
f"Development version mismatch!\n"
f"Only in TXT: {txt_dev - toml_dev}\n"
f"Only in TOML: {toml_dev - txt_dev}"
)
def test_environment_vs_toml():
"""2. Verifies that installed packages satisfy TOML requirements."""
root = get_root_path()
with open(root / "pyproject.toml", "rb") as f:
data = tomllib.load(f)
all_declared = parse_toml_section(data, "main") | parse_toml_section(data, "dev")
_verify_installation(all_declared, "TOML")
def test_environment_vs_requirements():
"""3. Verifies that installed packages satisfy .txt requirements."""
root = get_root_path()
all_txt_deps = (
parse_txt_requirements(root / "requirements.txt") |
parse_txt_requirements(root / "requirements-dev.txt")
)
_verify_installation(all_txt_deps, "requirements.txt")
def _verify_installation(package_set, source_name):
"""Helper to check if declared versions match installed versions."""
missing_or_wrong = []
for req in package_set:
# Split name from version to check installation status
# handles ==, >=, ~=, <=, > , <
import re
parts = re.split(r'==|>=|~=|<=|>|<', req)
name = parts[0].strip()
try:
installed_ver = importlib.metadata.version(name)
# If the config uses exact versioning '==', we can do a strict check
if "==" in req:
expected_ver = req.split("==")[1].strip()
if installed_ver != expected_ver:
missing_or_wrong.append(f"{name} (Installed: {installed_ver}, Expected: {expected_ver})")
except importlib.metadata.PackageNotFoundError:
missing_or_wrong.append(f"{name} (Not installed)")
if missing_or_wrong:
pytest.fail(f"Environment out of sync with {source_name}:\n" + "\n".join(missing_or_wrong))