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/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/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 bc6fa65..0fe5775 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 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 f9ebe5b..679f86c 100755 --- a/setup.sh +++ b/setup.sh @@ -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 diff --git a/static/css/adsb_dashboard.css b/static/css/adsb_dashboard.css index 78779ca..be0c142 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); } } @@ -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; 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/index.html b/templates/index.html index f2df7b1..a52ad70 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 @@ -10066,6 +10251,8 @@ decoder
📡433MHz - Sensor decoder
+
Meters - Utility meter decoder +
✈️Aircraft - Opens ADS-B Dashboard
📍APRS - Amateur radio @@ -10101,6 +10288,14 @@
  • Device intelligence builds profiles of recurring devices
  • +

    ⚡ Utility Meter Mode

    + +

    ✈️ Aircraft (Dashboard)