From ce232e05122226c30b33c0b894a29cc48d72e0d9 Mon Sep 17 00:00:00 2001 From: James Smith Date: Mon, 19 Jan 2026 17:25:24 +0000 Subject: [PATCH 1/8] Add flask-limiter to setup.sh dependency verification --- setup.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.sh b/setup.sh index f378624..d362c04 100755 --- a/setup.sh +++ b/setup.sh @@ -230,9 +230,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" From 5e4412879d7ab8af3c23322d5a6b0f8e00ecf616 Mon Sep 17 00:00:00 2001 From: James Smith Date: Mon, 19 Jan 2026 21:11:59 +0000 Subject: [PATCH 2/8] Fix ACARS sidebar expanding page height Add height constraints and overflow handling to keep the sidebar static within viewport while allowing internal scrolling. --- static/css/adsb_dashboard.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/static/css/adsb_dashboard.css b/static/css/adsb_dashboard.css index 78779ca..634ea92 100644 --- a/static/css/adsb_dashboard.css +++ b/static/css/adsb_dashboard.css @@ -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); } } @@ -269,6 +273,8 @@ body { 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,6 +289,8 @@ body { flex-direction: column; border: none; border-radius: 0; + min-height: 0; + overflow: hidden; } .acars-sidebar .panel::before { From df025f0409842f8d1a86f72b16b6ad6e901c7943 Mon Sep 17 00:00:00 2001 From: James Smith Date: Mon, 19 Jan 2026 21:18:41 +0000 Subject: [PATCH 3/8] Widen ACARS sidebar and fix controls visibility Increase sidebar width from 250px to 300px to prevent region dropdown from being cut off. Add flex layout to keep header and controls visible while messages area scrolls. --- static/css/adsb_dashboard.css | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/static/css/adsb_dashboard.css b/static/css/adsb_dashboard.css index 634ea92..be0c142 100644 --- a/static/css/adsb_dashboard.css +++ b/static/css/adsb_dashboard.css @@ -268,7 +268,7 @@ body { } .acars-sidebar-content { - width: 250px; + width: 300px; display: flex; flex-direction: column; overflow: hidden; @@ -297,6 +297,23 @@ body { 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; From ecc8dad2e27a5e61cab8eb3721c9958b8f7a33e4 Mon Sep 17 00:00:00 2001 From: SarahRose Date: Mon, 19 Jan 2026 21:42:01 -0500 Subject: [PATCH 4/8] Add rtlamr utility meter monitoring support - Added rtlamr mode for decoding utility meters (water, gas, electric) - Starts rtl_tcp server first, then connects rtlamr to it - Supports multiple message types: SCM, SCM+, IDM, NetIDM, R900, R900 BCD - Added frequency presets for 912 MHz (NA) and 868 MHz (EU) - Includes meter ID filtering and unique message options - Updated setup.sh to check and install rtlamr and rtl_tcp - Added UI components: navigation button, mode template, JavaScript functions - Integrated into SDR/RF dropdown menu with lightning bolt icon - Updates mode indicator with frequency when listening - Added help documentation and requirements section --- app.py | 8 +- routes/__init__.py | 2 + routes/rtlamr.py | 250 +++++++++++++++++++++++++++ setup.sh | 34 ++++ templates/index.html | 198 ++++++++++++++++++++- templates/partials/modes/rtlamr.html | 67 +++++++ 6 files changed, 557 insertions(+), 2 deletions(-) create mode 100644 routes/rtlamr.py create mode 100644 templates/partials/modes/rtlamr.html diff --git a/app.py b/app.py index e30646f..8bdef28 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/routes/__init__.py b/routes/__init__.py index b7960d1..ffb5f48 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -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) diff --git a/routes/rtlamr.py b/routes/rtlamr.py new file mode 100644 index 0000000..4268618 --- /dev/null +++ b/routes/rtlamr.py @@ -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 diff --git a/setup.sh b/setup.sh index d362c04..5b65465 100755 --- a/setup.sh +++ b/setup.sh @@ -136,8 +136,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_required "rtlamr" "Utility meter decoder" rtlamr check_required "dump1090" "ADS-B decoder" dump1090 check_required "acarsdec" "ACARS decoder" acarsdec @@ -332,6 +334,24 @@ install_macos_packages() { progress "Installing rtl_433" brew_install rtl_433 + progress "Installing rtlamr" + # rtlamr needs to be installed via go or binary + if ! cmd_exists rtlamr; then + if [[ -f "/home/rose/Compiled/rtlamr/rtlamr" ]]; then + info "Found rtlamr binary, linking to /usr/local/bin..." + if [[ -w /usr/local/bin ]]; then + ln -sf /home/rose/Compiled/rtlamr/rtlamr /usr/local/bin/rtlamr + else + sudo ln -sf /home/rose/Compiled/rtlamr/rtlamr /usr/local/bin/rtlamr + fi + ok "rtlamr linked successfully" + else + warn "rtlamr not found. Download from https://github.com/bemasher/rtlamr" + fi + else + ok "rtlamr already installed" + fi + progress "Installing dump1090" (brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew" @@ -602,6 +622,20 @@ install_debian_packages() { progress "Installing rtl_433" apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available" + progress "Installing rtlamr" + # rtlamr needs to be installed via go or binary + if ! cmd_exists rtlamr; then + if [[ -f "/home/rose/Compiled/rtlamr/rtlamr" ]]; then + info "Found rtlamr binary, installing to /usr/local/bin..." + $SUDO install -m 0755 /home/rose/Compiled/rtlamr/rtlamr /usr/local/bin/rtlamr + ok "rtlamr installed successfully" + else + warn "rtlamr not found. Download from https://github.com/bemasher/rtlamr" + fi + else + ok "rtlamr already installed" + fi + progress "Installing aircrack-ng" apt_install aircrack-ng || true diff --git a/templates/index.html b/templates/index.html index bf4ee39..c8ebec3 100644 --- a/templates/index.html +++ b/templates/index.html @@ -375,6 +375,8 @@ class="nav-label">Pager + ✈️Aircraft + ✈️ Aircraft @@ -542,6 +545,8 @@ {% include 'partials/modes/pager.html' %} {% include 'partials/modes/sensor.html' %} + + {% include 'partials/modes/rtlamr.html' %} {% include 'partials/modes/wifi.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 = 'METERS @ ' + freq + ' MHz'; + } else { + document.getElementById('activeModeIndicator').innerHTML = '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 += `
${label}: ${displayValue}
`; + } + } + + const timestamp = new Date().toLocaleTimeString(); + card.innerHTML = ` +
+ ${data.Type || 'Meter'} + ${timestamp} +
+
${dataItems}
+ `; + + 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 @@ -10064,6 +10249,8 @@ decoder
📡433MHz - Sensor decoder
+
Meters - Utility meter decoder +
✈️Aircraft - Opens ADS-B Dashboard
📍APRS - Amateur radio @@ -10099,6 +10286,14 @@
  • Device intelligence builds profiles of recurring devices
  • +

    ⚡ Utility Meter Mode

    +
      +
    • Decodes utility meter transmissions (water, gas, electric) using rtlamr
    • +
    • Supports ERT protocol on 912 MHz (North America) or 868 MHz (Europe)
    • +
    • Displays meter IDs and consumption data in real-time
    • +
    • Supports SCM, SCM+, IDM, NetIDM, and R900 message types
    • +
    +

    ✈️ Aircraft (Dashboard)

    • Opens the dedicated ADS-B Dashboard for aircraft tracking
    • @@ -10231,6 +10426,7 @@
      • Pager: RTL-SDR, rtl_fm, multimon-ng
      • 433MHz Sensors: RTL-SDR, rtl_433
      • +
      • Utility Meters: RTL-SDR, rtl_tcp, rtlamr
      • Aircraft (ADS-B): RTL-SDR, dump1090 or rtl_adsb
      • Aircraft (ACARS): Second RTL-SDR, acarsdec
      • APRS: RTL-SDR, direwolf or multimon-ng
      • diff --git a/templates/partials/modes/rtlamr.html b/templates/partials/modes/rtlamr.html new file mode 100644 index 0000000..af92918 --- /dev/null +++ b/templates/partials/modes/rtlamr.html @@ -0,0 +1,67 @@ + +
        +
        +

        Frequency

        +
        + + +
        +
        + + + +
        +
        + +
        +

        Settings

        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + +
        +

        Protocols

        +
        + rtlamr decodes utility meter transmissions (water, gas, electric) using ERT protocol. +
        +
        + + +
        +
        + + + +
        From d268e581bdffbff934e29cc9511868bf6f09c251 Mon Sep 17 00:00:00 2001 From: Jon Ander Oribe Date: Tue, 20 Jan 2026 07:07:47 +0100 Subject: [PATCH 5/8] Enhance login UX with JS feedback and update docs Added a new login.js script to provide visual feedback and prevent double submission on the login form. Updated login.html to include the script and wire up the login button. Clarified credential configuration instructions in README.md. --- README.md | 4 +++- static/js/core/login.js | 34 ++++++++++++++++++++++++++++++++++ templates/login.html | 3 ++- 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 static/js/core/login.js diff --git a/README.md b/README.md index 49bed63..e061067 100644 --- a/README.md +++ b/README.md @@ -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 admin:admin + +The credentials can be change in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py --- diff --git a/static/js/core/login.js b/static/js/core/login.js new file mode 100644 index 0000000..4547de0 --- /dev/null +++ b/static/js/core/login.js @@ -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); +} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html index 6662aa9..8c47d67 100644 --- a/templates/login.html +++ b/templates/login.html @@ -4,6 +4,7 @@ iNTERCEPT // Restricted Access + @@ -48,7 +49,7 @@ - From 664ae5b5ce5d825d815369c3e8ce47791b6fd2e6 Mon Sep 17 00:00:00 2001 From: Jon Ander Oribe Date: Tue, 20 Jan 2026 07:59:42 +0100 Subject: [PATCH 6/8] Update requirements.txt Added flask-limiter>=2.5.4 to the requirements --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index bc6fa65..791f646 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ # Core dependencies flask>=2.0.0 +flask-limiter>=2.5.4 requests>=2.28.0 # BLE scanning with manufacturer data detection (optional - for TSCM) From 1ef3e367eb0cfad6c8edcc4b476c415aa4a85aaf Mon Sep 17 00:00:00 2001 From: Jon Ander Oribe Date: Tue, 20 Jan 2026 10:20:13 +0100 Subject: [PATCH 7/8] Add new dependencies and sync requirement files Added 'bleak', 'flask-sock', and 'requests' to pyproject.toml and updated requirements.txt to include 'Werkzeug' and 'bleak'. Introduced tests/test_requirements.py to ensure consistency between requirements files and the installed environment. --- pyproject.toml | 3 + requirements.txt | 1 + tests/test_requirements.py | 109 +++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 tests/test_requirements.py diff --git a/pyproject.toml b/pyproject.toml index e42cf18..479ed47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/requirements.txt b/requirements.txt index 791f646..0fe5775 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ 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 diff --git a/tests/test_requirements.py b/tests/test_requirements.py new file mode 100644 index 0000000..27e4922 --- /dev/null +++ b/tests/test_requirements.py @@ -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)) \ No newline at end of file From ce204ce413b4a7bf0e3f9b299afc360d288c18a6 Mon Sep 17 00:00:00 2001 From: James Smith Date: Tue, 20 Jan 2026 13:16:14 +0000 Subject: [PATCH 8/8] Make rtlamr optional with interactive install prompt - Add check_optional() function for non-critical tools - Change rtlamr from required to optional tool - Add install_rtlamr_from_source() that auto-installs Go and compiles rtlamr - Prompt user during setup whether to install rtlamr - Fixes setup failure for users who don't need utility meter monitoring --- setup.sh | 90 ++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 71 insertions(+), 19 deletions(-) diff --git a/setup.sh b/setup.sh index 5b65465..941e1c1 100755 --- a/setup.sh +++ b/setup.sh @@ -128,6 +128,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=() @@ -139,7 +150,7 @@ check_tools() { 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_required "rtlamr" "Utility meter decoder" rtlamr + check_optional "rtlamr" "Utility meter decoder (requires Go)" rtlamr check_required "dump1090" "ADS-B decoder" dump1090 check_required "acarsdec" "ACARS decoder" acarsdec @@ -276,6 +287,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..." @@ -334,19 +388,16 @@ install_macos_packages() { progress "Installing rtl_433" brew_install rtl_433 - progress "Installing rtlamr" - # rtlamr needs to be installed via go or binary + progress "Installing rtlamr (optional)" + # rtlamr is optional - used for utility meter monitoring if ! cmd_exists rtlamr; then - if [[ -f "/home/rose/Compiled/rtlamr/rtlamr" ]]; then - info "Found rtlamr binary, linking to /usr/local/bin..." - if [[ -w /usr/local/bin ]]; then - ln -sf /home/rose/Compiled/rtlamr/rtlamr /usr/local/bin/rtlamr - else - sudo ln -sf /home/rose/Compiled/rtlamr/rtlamr /usr/local/bin/rtlamr - fi - ok "rtlamr linked successfully" + 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 "rtlamr not found. Download from https://github.com/bemasher/rtlamr" + warn "Skipping rtlamr installation. You can install it later if needed." fi else ok "rtlamr already installed" @@ -622,15 +673,16 @@ install_debian_packages() { progress "Installing rtl_433" apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available" - progress "Installing rtlamr" - # rtlamr needs to be installed via go or binary + progress "Installing rtlamr (optional)" + # rtlamr is optional - used for utility meter monitoring if ! cmd_exists rtlamr; then - if [[ -f "/home/rose/Compiled/rtlamr/rtlamr" ]]; then - info "Found rtlamr binary, installing to /usr/local/bin..." - $SUDO install -m 0755 /home/rose/Compiled/rtlamr/rtlamr /usr/local/bin/rtlamr - ok "rtlamr installed successfully" + 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 "rtlamr not found. Download from https://github.com/bemasher/rtlamr" + warn "Skipping rtlamr installation. You can install it later if needed." fi else ok "rtlamr already installed"