diff --git a/.gitignore b/.gitignore index 4c6d018..5c66355 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,6 @@ data/subghz/captures/ .env .env.* !.env.example + +# Local utility scripts +reset-sdr.* diff --git a/app.py b/app.py index db8145f..420f8a5 100644 --- a/app.py +++ b/app.py @@ -213,6 +213,11 @@ meteor_process = None meteor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) meteor_lock = threading.Lock() +# Generic OOK signal decoder +ook_process = None +ook_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +ook_lock = threading.Lock() + # Deauth Attack Detection deauth_detector = None deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) diff --git a/routes/__init__.py b/routes/__init__.py index 3f77052..82b297c 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -18,6 +18,7 @@ def register_blueprints(app): from .meshtastic import meshtastic_bp from .meteor_websocket import meteor_bp from .morse import morse_bp + from .ook import ook_bp from .offline import offline_bp from .pager import pager_bp from .radiosonde import radiosonde_bp @@ -81,6 +82,7 @@ def register_blueprints(app): app.register_blueprint(morse_bp) # CW/Morse code decoder app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking app.register_blueprint(system_bp) # System health monitoring + app.register_blueprint(ook_bp) # Generic OOK signal decoder # Initialize TSCM state with queue and lock from app import app as app_module diff --git a/routes/ook.py b/routes/ook.py new file mode 100644 index 0000000..87c209d --- /dev/null +++ b/routes/ook.py @@ -0,0 +1,290 @@ +"""Generic OOK signal decoder routes. + +Captures raw OOK frames using rtl_433's flex decoder and streams decoded +bit/hex data to the browser for live ASCII interpretation. Supports +PWM, PPM, and Manchester modulation with fully configurable pulse timing. +""" + +from __future__ import annotations + +import contextlib +import queue +import subprocess +import threading +from typing import Any + +from flask import Blueprint, Response, jsonify, request + +import app as app_module +from utils.event_pipeline import process_event +from utils.logging import sensor_logger as logger +from utils.ook import ook_parser_thread +from utils.process import register_process, safe_terminate, unregister_process +from utils.sdr import SDRFactory, SDRType +from utils.sse import sse_stream_fanout +from utils.validation import ( + validate_device_index, + validate_frequency, + validate_gain, + validate_ppm, + validate_rtl_tcp_host, + validate_rtl_tcp_port, +) + +ook_bp = Blueprint('ook', __name__) + +# Track which device is being used +ook_active_device: int | None = None + +# Supported modulation schemes → rtl_433 flex decoder modulation string +_MODULATION_MAP = { + 'pwm': 'OOK_PWM', + 'ppm': 'OOK_PPM', + 'manchester': 'OOK_MC_ZEROBIT', +} + + +def _validate_encoding(value: Any) -> str: + enc = str(value).lower().strip() + if enc not in _MODULATION_MAP: + raise ValueError(f"encoding must be one of: {', '.join(_MODULATION_MAP)}") + return enc + + +@ook_bp.route('/ook/start', methods=['POST']) +def start_ook() -> Response: + global ook_active_device + + with app_module.ook_lock: + if app_module.ook_process: + return jsonify({'status': 'error', 'message': 'OOK decoder already running'}), 409 + + data = request.json or {} + + try: + freq = validate_frequency(data.get('frequency', '433.920')) + 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 + + try: + encoding = _validate_encoding(data.get('encoding', 'pwm')) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + # OOK flex decoder timing parameters + try: + short_pulse = int(data.get('short_pulse', 300)) + long_pulse = int(data.get('long_pulse', 600)) + reset_limit = int(data.get('reset_limit', 8000)) + gap_limit = int(data.get('gap_limit', 5000)) + tolerance = int(data.get('tolerance', 150)) + min_bits = int(data.get('min_bits', 8)) + except (ValueError, TypeError) as e: + return jsonify({'status': 'error', 'message': f'Invalid timing parameter: {e}'}), 400 + deduplicate = bool(data.get('deduplicate', False)) + + rtl_tcp_host = data.get('rtl_tcp_host') or None + rtl_tcp_port = data.get('rtl_tcp_port', 1234) + + if not rtl_tcp_host: + device_int = int(device) + error = app_module.claim_sdr_device(device_int, 'ook') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error, + }), 409 + ook_active_device = device_int + + while not app_module.ook_queue.empty(): + try: + app_module.ook_queue.get_nowait() + except queue.Empty: + break + + sdr_type_str = data.get('sdr_type', 'rtlsdr') + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR + + if rtl_tcp_host: + try: + rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) + rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port) + logger.info(f'Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}') + else: + sdr_device = SDRFactory.create_default_device(sdr_type, index=device) + + builder = SDRFactory.get_builder(sdr_device.sdr_type) + bias_t = data.get('bias_t', False) + + # Build base ISM command then replace protocol flags with flex decoder + cmd = builder.build_ism_command( + device=sdr_device, + frequency_mhz=freq, + gain=float(gain) if gain and gain != '0' else None, + ppm=int(ppm) if ppm and ppm != '0' else None, + bias_t=bias_t, + ) + + modulation = _MODULATION_MAP[encoding] + flex_spec = ( + f'n=ook,m={modulation},' + f's={short_pulse},l={long_pulse},' + f'r={reset_limit},g={gap_limit},' + f't={tolerance},bits>={min_bits}' + ) + + # Strip any existing -R flags from the base command + filtered_cmd: list[str] = [] + skip_next = False + for arg in cmd: + if skip_next: + skip_next = False + continue + if arg == '-R': + skip_next = True + continue + filtered_cmd.append(arg) + + filtered_cmd.extend(['-M', 'level', '-R', '0', '-X', flex_spec]) + + full_cmd = ' '.join(filtered_cmd) + logger.info(f'OOK decoder running: {full_cmd}') + + try: + rtl_process = subprocess.Popen( + filtered_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + register_process(rtl_process) + + _stderr_noise = ('bitbuffer_add_bit', 'row count limit') + + def monitor_stderr() -> None: + for line in rtl_process.stderr: + err_text = line.decode('utf-8', errors='replace').strip() + if err_text and not any(n in err_text for n in _stderr_noise): + logger.debug(f'[rtl_433/ook] {err_text}') + + stderr_thread = threading.Thread(target=monitor_stderr) + stderr_thread.daemon = True + stderr_thread.start() + + stop_event = threading.Event() + parser_thread = threading.Thread( + target=ook_parser_thread, + args=( + rtl_process.stdout, + app_module.ook_queue, + stop_event, + encoding, + deduplicate, + ), + ) + parser_thread.daemon = True + parser_thread.start() + + app_module.ook_process = rtl_process + app_module.ook_process._stop_parser = stop_event + app_module.ook_process._parser_thread = parser_thread + + try: + app_module.ook_queue.put_nowait({'type': 'status', 'status': 'started'}) + except queue.Full: + logger.warning("OOK 'started' status dropped — queue full") + + return jsonify({ + 'status': 'started', + 'command': full_cmd, + 'encoding': encoding, + 'modulation': modulation, + 'flex_spec': flex_spec, + 'deduplicate': deduplicate, + }) + + except FileNotFoundError as e: + if ook_active_device is not None: + app_module.release_sdr_device(ook_active_device) + ook_active_device = None + return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}), 400 + + except Exception as e: + try: + rtl_process.terminate() + rtl_process.wait(timeout=2) + except Exception: + with contextlib.suppress(Exception): + rtl_process.kill() + unregister_process(rtl_process) + if ook_active_device is not None: + app_module.release_sdr_device(ook_active_device) + ook_active_device = None + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@ook_bp.route('/ook/stop', methods=['POST']) +def stop_ook() -> Response: + global ook_active_device + + with app_module.ook_lock: + if app_module.ook_process: + stop_event = getattr(app_module.ook_process, '_stop_parser', None) + if stop_event: + stop_event.set() + + safe_terminate(app_module.ook_process) + unregister_process(app_module.ook_process) + app_module.ook_process = None + + if ook_active_device is not None: + app_module.release_sdr_device(ook_active_device) + ook_active_device = None + + try: + app_module.ook_queue.put_nowait({'type': 'status', 'status': 'stopped'}) + except queue.Full: + logger.warning("OOK 'stopped' status dropped — queue full") + return jsonify({'status': 'stopped'}) + + return jsonify({'status': 'not_running'}) + + +@ook_bp.route('/ook/status') +def ook_status() -> Response: + with app_module.ook_lock: + running = ( + app_module.ook_process is not None + and app_module.ook_process.poll() is None + ) + return jsonify({'running': running}) + + +@ook_bp.route('/ook/stream') +def ook_stream() -> Response: + def _on_msg(msg: dict[str, Any]) -> None: + process_event('ook', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=app_module.ook_queue, + channel_key='ook', + timeout=1.0, + keepalive_interval=30.0, + on_message=_on_msg, + ), + 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/static/js/core/cheat-sheets.js b/static/js/core/cheat-sheets.js index f223079..bed4336 100644 --- a/static/js/core/cheat-sheets.js +++ b/static/js/core/cheat-sheets.js @@ -27,6 +27,21 @@ const CheatSheets = (function () { radiosonde: { title: 'Radiosonde Tracker', icon: '🎈', hardware: 'RTL-SDR dongle', description: 'Tracks weather balloons via radiosonde telemetry using radiosonde_auto_rx.', whatToExpect: 'Position, altitude, temperature, humidity, pressure from active sondes.', tips: ['Sondes transmit on 400–406 MHz', 'Set your region to narrow the scan range', 'Gain 40 dB is a good starting point'] }, morse: { title: 'CW/Morse Decoder', icon: '📡', hardware: 'RTL-SDR + HF antenna (or upconverter)', description: 'Decodes CW Morse code via Goertzel tone detection or OOK envelope detection.', whatToExpect: 'Decoded Morse characters, WPM estimate, signal level.', tips: ['CW Tone mode for HF amateur bands (e.g. 7.030, 14.060 MHz)', 'OOK Envelope mode for ISM/UHF signals', 'Use band presets for quick tuning to CW sub-bands'] }, meteor: { title: 'Meteor Scatter', icon: '☄️', hardware: 'RTL-SDR + VHF antenna (143 MHz)', description: 'Monitors VHF beacon reflections from meteor ionization trails.', whatToExpect: 'Waterfall display with transient ping detections and event logging.', tips: ['GRAVES radar at 143.050 MHz is the primary target', 'Use a Yagi pointed south (from Europe) for best results', 'Peak activity during annual meteor showers (Perseids, Geminids)'] }, + ook: { + title: 'OOK Signal Decoder', + icon: '📡', + hardware: 'RTL-SDR dongle', + description: 'Decodes raw On-Off Keying (OOK) signals via rtl_433 flex decoder. Captures frames with configurable pulse timing and displays raw bits, hex, and ASCII — useful for reverse-engineering unknown ISM-band protocols.', + whatToExpect: 'Decoded bit sequences, hex payloads, and ASCII interpretation. Each frame shows bit count, timestamp, and optional RSSI.', + tips: [ + 'Identifying modulationPWM: pulse widths vary (short=0, long=1), gaps constant — most common for ISM remotes/sensors. PPM: pulses constant, gap widths encode data. Manchester: self-clocking, equal-width pulses, data in transitions.', + 'Finding pulse timing — Run rtl_433 -f 433.92M -A in a terminal to auto-analyze signals. It prints detected pulse widths (short/long) and gap timings. Use those values in the Short/Long Pulse fields.', + 'Common ISM timings — 300/600µs (weather stations, door sensors), 400/800µs (car keyfobs), 500/1500µs (garage doors, doorbells), 500µs Manchester (tire pressure monitors).', + 'Frequencies to try — 315 MHz (North America keyfobs), 433.920 MHz (global ISM), 868 MHz (Europe ISM), 915 MHz (US ISM/meters).', + 'Troubleshooting — Garbled output? Try halving or doubling pulse timings. No frames? Increase tolerance (±200–300µs). Too many frames? Enable deduplication. Wrong characters? Toggle MSB/LSB bit order.', + 'Tolerance & reset — Tolerance is how much timing can drift (±150µs default). Reset limit is the silence gap that ends a frame (8000µs). Lower gap limit if frames are merging together.', + ] + }, }; function show(mode) { diff --git a/static/js/modes/ook.js b/static/js/modes/ook.js new file mode 100644 index 0000000..8221f4a --- /dev/null +++ b/static/js/modes/ook.js @@ -0,0 +1,575 @@ +/** + * Generic OOK Signal Decoder module. + * + * IIFE providing start/stop controls, SSE streaming, and a live-updating + * frame log with configurable bit order (MSB/LSB) and ASCII interpretation. + * The backend sends raw bits; all byte grouping and ASCII display is done + * here so bit order can be flipped without restarting the decoder. + */ +var OokMode = (function () { + 'use strict'; + + var DEFAULT_FREQ_PRESETS = ['433.920', '315.000', '868.000', '915.000']; + var MAX_FRAMES = 5000; + + var state = { + running: false, + initialized: false, + eventSource: null, + frames: [], // raw frame objects from SSE + frameCount: 0, + bitOrder: 'msb', // 'msb' | 'lsb' + filterQuery: '', // active hex/ascii filter + command: '', // the rtl_433 command being run + }; + + // ---- Initialization ---- + + function init() { + if (state.initialized) { + checkStatus(); + return; + } + state.initialized = true; + renderPresets(); + checkStatus(); + } + + function destroy() { + disconnectSSE(); + } + + // ---- Status ---- + + function checkStatus() { + fetch('/ook/status') + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.running) { + state.running = true; + updateUI(true); + connectSSE(); + } else { + state.running = false; + updateUI(false); + } + }) + .catch(function () {}); + } + + // ---- Start / Stop ---- + + function start() { + if (state.running) return; + + var remoteSDR = typeof getRemoteSDRConfig === 'function' ? getRemoteSDRConfig() : null; + if (remoteSDR === false) return; + + var payload = { + frequency: document.getElementById('ookFrequency').value || '433.920', + gain: document.getElementById('ookGain').value || '0', + ppm: document.getElementById('ookPPM').value || '0', + device: document.getElementById('deviceSelect')?.value || '0', + sdr_type: document.getElementById('sdrTypeSelect')?.value || 'rtlsdr', + encoding: document.getElementById('ookEncoding').value || 'pwm', + short_pulse: document.getElementById('ookShortPulse').value || '300', + long_pulse: document.getElementById('ookLongPulse').value || '600', + reset_limit: document.getElementById('ookResetLimit').value || '8000', + gap_limit: document.getElementById('ookGapLimit').value || '5000', + tolerance: document.getElementById('ookTolerance').value || '150', + min_bits: document.getElementById('ookMinBits').value || '8', + deduplicate: document.getElementById('ookDeduplicate')?.checked || false, + bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false, + }; + if (remoteSDR) { + payload.rtl_tcp_host = remoteSDR.host; + payload.rtl_tcp_port = remoteSDR.port; + } + + fetch('/ook/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.status === 'started') { + state.running = true; + state.frames = []; + state.frameCount = 0; + updateUI(true); + connectSSE(); + clearOutput(); + showCommand(data.command || ''); + } else { + alert('Error: ' + (data.message || 'Unknown error')); + } + }) + .catch(function (err) { + alert('Failed to start OOK decoder: ' + err); + }); + } + + function stop() { + fetch('/ook/stop', { method: 'POST' }) + .then(function (r) { return r.json(); }) + .then(function () { + state.running = false; + updateUI(false); + disconnectSSE(); + }) + .catch(function () {}); + } + + // ---- SSE ---- + + function connectSSE() { + disconnectSSE(); + var es = new EventSource('/ook/stream'); + es.onmessage = function (e) { + try { + var msg = JSON.parse(e.data); + handleMessage(msg); + } catch (_) {} + }; + es.onerror = function () {}; + state.eventSource = es; + } + + function disconnectSSE() { + if (state.eventSource) { + state.eventSource.close(); + state.eventSource = null; + } + } + + function handleMessage(msg) { + if (msg.type === 'ook_frame') { + handleFrame(msg); + } else if (msg.type === 'status') { + if (msg.status === 'stopped') { + state.running = false; + updateUI(false); + disconnectSSE(); + } + } else if (msg.type === 'error') { + console.error('OOK error:', msg.text); + } + } + + // ---- Frame handling ---- + + function handleFrame(msg) { + state.frames.push(msg); + state.frameCount++; + + // Trim oldest frames when buffer exceeds cap + if (state.frames.length > MAX_FRAMES) { + state.frames.splice(0, state.frames.length - MAX_FRAMES); + var panel = document.getElementById('ookOutput'); + if (panel && panel.firstChild) panel.removeChild(panel.firstChild); + } + + var countEl = document.getElementById('ookFrameCount'); + if (countEl) countEl.textContent = state.frameCount + ' frames'; + var barEl = document.getElementById('ookStatusBarFrames'); + if (barEl) barEl.textContent = state.frameCount + ' frames'; + + appendFrameEntry(msg, state.bitOrder); + } + + // ---- Bit interpretation ---- + + /** + * Interpret a raw bit string as bytes and attempt ASCII. + * @param {string} bits - MSB-first bit string from backend + * @param {string} order - 'msb' | 'lsb' + * @returns {{hex: string, ascii: string, printable: string}} + */ + function interpretBits(bits, order) { + var hexChars = []; + var asciiChars = []; + var printableChars = []; + + for (var i = 0; i + 8 <= bits.length; i += 8) { + var byteBits = bits.slice(i, i + 8); + if (order === 'lsb') { + byteBits = byteBits.split('').reverse().join(''); + } + var byteVal = parseInt(byteBits, 2); + hexChars.push(byteVal.toString(16).padStart(2, '0')); + + if (byteVal >= 0x20 && byteVal <= 0x7E) { + asciiChars.push(String.fromCharCode(byteVal)); + printableChars.push(String.fromCharCode(byteVal)); + } else { + asciiChars.push('.'); + } + } + + return { + hex: hexChars.join(''), + ascii: asciiChars.join(''), + printable: printableChars.join(''), + }; + } + + function appendFrameEntry(msg, order) { + var panel = document.getElementById('ookOutput'); + if (!panel) return; + + var interp = interpretBits(msg.bits, order); + var hasPrintable = interp.printable.length > 0; + + var div = document.createElement('div'); + div.className = 'ook-frame'; + div.dataset.bits = msg.bits || ''; + div.dataset.bitCount = msg.bit_count; + div.dataset.inverted = msg.inverted ? '1' : '0'; + + var color = hasPrintable ? '#00ff88' : 'var(--text-dim)'; + var suffix = ''; + if (msg.inverted) suffix += ' (inv)'; + + var rssiStr = (msg.rssi !== undefined && msg.rssi !== null) + ? ' ' + msg.rssi.toFixed(1) + ' dB SNR' + : ''; + + div.innerHTML = + '' + msg.timestamp + '' + + ' [' + msg.bit_count + 'b]' + + rssiStr + suffix + + '
' + + '' + + 'hex: ' + interp.hex + + '' + + '
' + + '' + + 'ascii: ' + (typeof escapeHtml === 'function' ? escapeHtml(interp.ascii) : interp.ascii) + + ''; + + div.style.cssText = 'font-size:11px; padding: 4px 0; border-bottom: 1px solid #1a1a1a; line-height:1.6;'; + + // Apply current filter + if (state.filterQuery) { + var q = state.filterQuery; + if (!interp.hex.includes(q) && !interp.ascii.toLowerCase().includes(q)) { + div.style.display = 'none'; + } else { + div.style.background = 'rgba(0,255,136,0.05)'; + } + } + + panel.appendChild(div); + if (typeof autoScroll === 'undefined' || autoScroll) { + panel.scrollTop = panel.scrollHeight; + } + } + + // ---- Bit order toggle ---- + + function setBitOrder(order) { + state.bitOrder = order; + + // Update button states + var msbBtn = document.getElementById('ookBitMSB'); + var lsbBtn = document.getElementById('ookBitLSB'); + if (msbBtn) msbBtn.style.background = order === 'msb' ? 'var(--accent)' : ''; + if (msbBtn) msbBtn.style.color = order === 'msb' ? '#000' : ''; + if (lsbBtn) lsbBtn.style.background = order === 'lsb' ? 'var(--accent)' : ''; + if (lsbBtn) lsbBtn.style.color = order === 'lsb' ? '#000' : ''; + + // Re-render all stored frames + var panel = document.getElementById('ookOutput'); + if (!panel) return; + panel.innerHTML = ''; + state.frames.forEach(function (msg) { + appendFrameEntry(msg, order); + }); + } + + // ---- Output panel ---- + + function clearOutput() { + var panel = document.getElementById('ookOutput'); + if (panel) panel.innerHTML = ''; + state.frames = []; + state.frameCount = 0; + var countEl = document.getElementById('ookFrameCount'); + if (countEl) countEl.textContent = '0 frames'; + var barEl = document.getElementById('ookStatusBarFrames'); + if (barEl) barEl.textContent = '0 frames'; + + // Hide output panel if not currently running (no frames to show) + if (!state.running) { + var outputPanel = document.getElementById('ookOutputPanel'); + if (outputPanel) outputPanel.style.display = 'none'; + } + } + + function exportLog() { + var lines = ['timestamp,bit_count,rssi_db,hex_msb,ascii_msb,inverted']; + state.frames.forEach(function (msg) { + var interp = interpretBits(msg.bits, 'msb'); + lines.push([ + msg.timestamp, + msg.bit_count, + msg.rssi !== undefined && msg.rssi !== null ? msg.rssi : '', + interp.hex, + '"' + interp.ascii.replace(/"/g, '""') + '"', + msg.inverted, + ].join(',')); + }); + var blob = new Blob([lines.join('\n')], { type: 'text/csv' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'ook_frames.csv'; + a.click(); + URL.revokeObjectURL(url); + } + + function exportJSON() { + if (state.frames.length === 0) { alert('No frames to export'); return; } + var out = state.frames.map(function (msg) { + var interp = interpretBits(msg.bits, state.bitOrder); + return { + timestamp: msg.timestamp, + bit_count: msg.bit_count, + rssi: (msg.rssi !== undefined && msg.rssi !== null) ? msg.rssi : null, + hex: interp.hex, + ascii: interp.ascii, + inverted: msg.inverted, + bits: msg.bits, + }; + }); + var blob = new Blob([JSON.stringify(out, null, 2)], { type: 'application/json' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'ook_frames.json'; + a.click(); + URL.revokeObjectURL(url); + } + + // ---- Command display ---- + + function showCommand(cmd) { + state.command = cmd; + var display = document.getElementById('ookCommandDisplay'); + var text = document.getElementById('ookCommandText'); + if (display && text && cmd) { + text.textContent = cmd; + display.style.display = 'block'; + } + } + + function copyCommand() { + if (state.command && navigator.clipboard) { + navigator.clipboard.writeText(state.command); + } + } + + // ---- Modulation selector ---- + + function setEncoding(enc) { + document.getElementById('ookEncoding').value = enc; + + // Update button highlight + ['pwm', 'ppm', 'manchester'].forEach(function (e) { + var btn = document.getElementById('ookEnc_' + e); + if (!btn) return; + if (e === enc) { + btn.style.background = 'var(--accent)'; + btn.style.color = '#000'; + } else { + btn.style.background = ''; + btn.style.color = ''; + } + }); + + // Update timing hint + var hints = { + pwm: 'Short pulse = 0, long pulse = 1. Most common for ISM OOK.', + ppm: 'Short gap = 0, long gap = 1. Pulse position encoding.', + manchester: 'Rising edge = 1, falling edge = 0. Self-clocking.', + }; + var hint = document.getElementById('ookEncodingHint'); + if (hint) hint.textContent = hints[enc] || ''; + } + + function setFreq(mhz) { + var el = document.getElementById('ookFrequency'); + if (el) el.value = mhz; + } + + // ---- Frequency presets (localStorage) ---- + + function loadPresets() { + var saved = localStorage.getItem('ookFreqPresets'); + return saved ? JSON.parse(saved) : DEFAULT_FREQ_PRESETS.slice(); + } + + function savePresets(presets) { + localStorage.setItem('ookFreqPresets', JSON.stringify(presets)); + } + + function renderPresets() { + var container = document.getElementById('ookPresetButtons'); + if (!container) return; + var presets = loadPresets(); + container.innerHTML = presets.map(function (freq) { + return ''; + }).join(''); + } + + function addPreset() { + var input = document.getElementById('ookNewPresetFreq'); + if (!input) return; + var freq = input.value.trim(); + if (!freq || isNaN(parseFloat(freq))) { + alert('Enter a valid frequency (MHz)'); + return; + } + var presets = loadPresets(); + if (presets.indexOf(freq) === -1) { + presets.push(freq); + savePresets(presets); + renderPresets(); + } + input.value = ''; + } + + function removePreset(freq) { + if (!confirm('Remove preset ' + freq + ' MHz?')) return; + var presets = loadPresets().filter(function (p) { return p !== freq; }); + savePresets(presets); + renderPresets(); + } + + function resetPresets() { + if (!confirm('Reset to default presets?')) return; + savePresets(DEFAULT_FREQ_PRESETS.slice()); + renderPresets(); + } + + /** + * Apply a timing preset — fills all six pulse timing fields at once. + * @param {number} s Short pulse (µs) + * @param {number} l Long pulse (µs) + * @param {number} r Reset/gap limit (µs) + * @param {number} g Gap limit (µs) + * @param {number} t Tolerance (µs) + * @param {number} b Min bits + */ + function setTiming(s, l, r, g, t, b) { + var fields = { + ookShortPulse: s, + ookLongPulse: l, + ookResetLimit: r, + ookGapLimit: g, + ookTolerance: t, + ookMinBits: b, + }; + Object.keys(fields).forEach(function (id) { + var el = document.getElementById(id); + if (el) el.value = fields[id]; + }); + } + + // ---- Auto bit-order suggestion ---- + + /** + * Count printable chars for MSB and LSB across all stored frames, + * then switch to whichever produces more readable output. + */ + function suggestBitOrder() { + if (state.frames.length === 0) return; + var msbCount = 0, lsbCount = 0; + state.frames.forEach(function (msg) { + msbCount += interpretBits(msg.bits, 'msb').printable.length; + lsbCount += interpretBits(msg.bits, 'lsb').printable.length; + }); + var best = msbCount >= lsbCount ? 'msb' : 'lsb'; + setBitOrder(best); + var label = document.getElementById('ookSuggestLabel'); + if (label) { + var winner = best === 'msb' ? msbCount : lsbCount; + label.textContent = best.toUpperCase() + ' (' + winner + ' printable)'; + label.style.color = '#00ff88'; + } + } + + // ---- Pattern search / filter ---- + + /** + * Show only frames whose hex or ASCII interpretation contains the query. + * Clears filter when query is empty. + * @param {string} query + */ + function filterFrames(query) { + state.filterQuery = query.toLowerCase().trim(); + var q = state.filterQuery; + var panel = document.getElementById('ookOutput'); + if (!panel) return; + var divs = panel.querySelectorAll('.ook-frame'); + divs.forEach(function (div) { + if (!q) { + div.style.display = ''; + div.style.background = ''; + return; + } + var bits = div.dataset.bits || ''; + var interp = interpretBits(bits, state.bitOrder); + var match = interp.hex.includes(q) || interp.ascii.toLowerCase().includes(q); + div.style.display = match ? '' : 'none'; + div.style.background = match ? 'rgba(0,255,136,0.05)' : ''; + }); + } + + // ---- UI ---- + + function updateUI(running) { + var startBtn = document.getElementById('ookStartBtn'); + var stopBtn = document.getElementById('ookStopBtn'); + var indicator = document.getElementById('ookStatusIndicator'); + var statusText = document.getElementById('ookStatusText'); + + if (startBtn) startBtn.style.display = running ? 'none' : ''; + if (stopBtn) stopBtn.style.display = running ? '' : 'none'; + if (indicator) indicator.style.background = running ? '#00ff88' : 'var(--text-dim)'; + if (statusText) statusText.textContent = running ? 'Listening' : 'Standby'; + + // Keep output panel visible if there are frames to review (even after stopping) + var outputPanel = document.getElementById('ookOutputPanel'); + if (outputPanel) { + var showPanel = running || state.frames.length > 0; + outputPanel.style.display = showPanel ? 'flex' : 'none'; + } + } + + // ---- Public API ---- + + return { + init: init, + destroy: destroy, + start: start, + stop: stop, + setFreq: setFreq, + addPreset: addPreset, + removePreset: removePreset, + resetPresets: resetPresets, + renderPresets: renderPresets, + setEncoding: setEncoding, + setTiming: setTiming, + setBitOrder: setBitOrder, + suggestBitOrder: suggestBitOrder, + filterFrames: filterFrames, + clearOutput: clearOutput, + exportLog: exportLog, + exportJSON: exportJSON, + copyCommand: copyCommand, + }; +})(); diff --git a/templates/index.html b/templates/index.html index 0a84bbc..8538606 100644 --- a/templates/index.html +++ b/templates/index.html @@ -287,6 +287,10 @@ Morse + @@ -697,6 +701,8 @@ {% include 'partials/modes/morse.html' %} + {% include 'partials/modes/ook.html' %} + {% include 'partials/modes/space-weather.html' %} {% include 'partials/modes/tscm.html' %} @@ -3285,6 +3291,36 @@ + + +
@@ -3356,6 +3392,7 @@ + @@ -3515,6 +3552,7 @@ waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' }, morse: { label: 'Morse', indicator: 'MORSE', outputTitle: 'CW/Morse Decoder', group: 'signals' }, system: { label: 'System', indicator: 'SYSTEM', outputTitle: 'System Health Monitor', group: 'system' }, + ook: { label: 'OOK Decoder', indicator: 'OOK', outputTitle: 'OOK Signal Decoder', group: 'signals' }, }; const validModes = new Set(Object.keys(modeCatalog)); window.interceptModeCatalog = Object.assign({}, modeCatalog); @@ -4069,6 +4107,7 @@ vdl2: () => { if (vdl2MainEventSource) { vdl2MainEventSource.close(); vdl2MainEventSource = null; } }, radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } }, meteor: () => typeof MeteorScatter !== 'undefined' && MeteorScatter.destroy?.(), + ook: () => typeof OokMode !== 'undefined' && OokMode.destroy?.(), }; return moduleDestroyMap[mode] || null; } @@ -4363,6 +4402,7 @@ document.getElementById('morseMode')?.classList.toggle('active', mode === 'morse'); document.getElementById('meteorMode')?.classList.toggle('active', mode === 'meteor'); document.getElementById('systemMode')?.classList.toggle('active', mode === 'system'); + document.getElementById('ookMode')?.classList.toggle('active', mode === 'ook'); const pagerStats = document.getElementById('pagerStats'); @@ -4462,6 +4502,8 @@ if (morseOutputPanel && mode !== 'morse') morseOutputPanel.style.display = 'none'; const morseDiagLog = document.getElementById('morseDiagLog'); if (morseDiagLog && mode !== 'morse') morseDiagLog.style.display = 'none'; + const ookOutputPanel = document.getElementById('ookOutputPanel'); + if (ookOutputPanel && mode !== 'ook') ookOutputPanel.style.display = 'none'; // Update output panel title based on mode const outputTitle = document.getElementById('outputTitle'); @@ -4503,16 +4545,17 @@ // Show RTL-SDR device section for modes that use it const rtlDeviceSection = document.getElementById('rtlDeviceSection'); if (rtlDeviceSection) { - rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'morse' || mode === 'radiosonde' || mode === 'meteor') ? 'block' : 'none'; + rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'morse' || mode === 'radiosonde' || mode === 'meteor' || mode === 'ook') ? 'block' : 'none'; // Save original sidebar position of SDR device section (once) if (!rtlDeviceSection._origParent) { rtlDeviceSection._origParent = rtlDeviceSection.parentNode; rtlDeviceSection._origNext = rtlDeviceSection.nextElementSibling; } - // For morse/radiosonde/meteor modes, move SDR device section inside the panel after the title + // For morse/radiosonde/meteor/ook modes, move SDR device section inside the panel after the title const morsePanel = document.getElementById('morseMode'); const radiosondePanel = document.getElementById('radiosondeMode'); const meteorPanel = document.getElementById('meteorMode'); + const ookPanel = document.getElementById('ookMode'); if (mode === 'morse' && morsePanel) { const firstSection = morsePanel.querySelector('.section'); if (firstSection) firstSection.after(rtlDeviceSection); @@ -4522,6 +4565,9 @@ } else if (mode === 'meteor' && meteorPanel) { const firstSection = meteorPanel.querySelector('.section'); if (firstSection) firstSection.after(rtlDeviceSection); + } else if (mode === 'ook' && ookPanel) { + const firstSection = ookPanel.querySelector('.section'); + if (firstSection) firstSection.after(rtlDeviceSection); } else if (rtlDeviceSection._origParent && rtlDeviceSection.parentNode !== rtlDeviceSection._origParent) { // Restore to original sidebar position when leaving morse mode if (rtlDeviceSection._origNext) { @@ -4541,7 +4587,7 @@ // Hide output console for modes with their own visualizations const outputEl = document.getElementById('output'); const statusBar = document.querySelector('.status-bar'); - if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall' || mode === 'morse' || mode === 'meteor' || mode === 'system') ? 'none' : 'block'; + if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall' || mode === 'morse' || mode === 'meteor' || mode === 'system' || mode === 'ook') ? 'none' : 'block'; if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'morse' || mode === 'meteor' || mode === 'system') ? 'none' : 'flex'; // Restore sidebar when leaving Meshtastic mode (user may have collapsed it) @@ -4626,6 +4672,8 @@ MeteorScatter.init(); } else if (mode === 'system') { SystemHealth.init(); + } else if (mode === 'ook') { + OokMode.init(); } // Waterfall destroy is now handled by moduleDestroyMap above. @@ -5639,6 +5687,7 @@ let allMessages = []; function exportCSV() { + if (currentMode === 'ook') { OokMode.exportLog(); return; } if (allMessages.length === 0) { alert('No messages to export'); return; @@ -5660,6 +5709,7 @@ } function exportJSON() { + if (currentMode === 'ook') { OokMode.exportJSON(); return; } if (allMessages.length === 0) { alert('No messages to export'); return; @@ -6845,6 +6895,7 @@ } function clearMessages() { + if (currentMode === 'ook') { OokMode.clearOutput(); return; } document.getElementById('output').innerHTML = `
Messages cleared. ${isRunning || isSensorRunning ? 'Waiting for new messages...' : 'Start decoding to receive messages.'} @@ -13051,7 +13102,7 @@
`; - // Add "Listen" button for RF signals + // Add "Listen" and "Decode (OOK)" buttons for RF signals if (protocol === 'rf' && device.frequency) { const freq = device.frequency; html += ` @@ -13061,6 +13112,10 @@ + `; } @@ -13074,7 +13129,7 @@
- ${protocol === 'rf' ? 'Listen buttons open Spectrum Waterfall. ' : ''}Known devices are excluded from threat scoring in future sweeps. + ${protocol === 'rf' ? 'Listen opens Spectrum Waterfall. Decode (OOK) opens the OOK decoder tuned to this frequency. ' : ''}Known devices are excluded from threat scoring in future sweeps.
`; @@ -13870,6 +13925,17 @@ }, 300); } + function decodeWithOok(frequency) { + // Close the TSCM modal and switch to OOK decoder with the detected frequency pre-filled + closeTscmDeviceModal(); + switchMode('ook'); + setTimeout(function () { + if (typeof OokMode !== 'undefined' && typeof OokMode.setFreq === 'function') { + OokMode.setFreq(parseFloat(frequency).toFixed(3)); + } + }, 300); + } + async function showDevicesByCategory(category) { const modal = document.getElementById('tscmDeviceModal'); const content = document.getElementById('tscmDeviceModalContent'); diff --git a/templates/partials/modes/ook.html b/templates/partials/modes/ook.html new file mode 100644 index 0000000..eb2919d --- /dev/null +++ b/templates/partials/modes/ook.html @@ -0,0 +1,153 @@ + +
+
+

OOK Signal Decoder

+

+ Decode raw OOK (On-Off Keying) signals via rtl_433 flex decoder. + Captures frames with configurable pulse timing and displays raw bits, + hex, and attempted ASCII — useful for unknown ISM-band protocols. +

+
+ +
+

Frequency

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

SDR Settings

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

Modulation

+
+
+ + + +
+ +

+ Short pulse = 0, long pulse = 1. Most common for ISM OOK. +

+
+
+ +
+

Pulse Timing

+

+ Pulse widths in microseconds for the flex decoder. +

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

Options

+
+ +

+ Suppress consecutive frames with identical hex content. + Useful when a transmitter repeats the same packet multiple times. +

+
+
+ + +
+
+ + Standby + 0 frames +
+
+ +
+

+ Uses rtl_433 with a custom flex decoder. Requires rtl_433 installed. + Works on any OOK/ASK signal in the SDR's frequency range. +

+ +
+ + + +
diff --git a/templates/partials/nav.html b/templates/partials/nav.html index 5e05ce9..41bb856 100644 --- a/templates/partials/nav.html +++ b/templates/partials/nav.html @@ -68,6 +68,7 @@ {{ mode_item('subghz', 'SubGHz', '') }} {{ mode_item('waterfall', 'Waterfall', '') }} {{ mode_item('morse', 'Morse', '') }} + {{ mode_item('ook', 'OOK Decoder', '') }}
@@ -218,6 +219,7 @@ {{ mobile_item('rtlamr', 'Meters', '') }} {{ mobile_item('subghz', 'SubGHz', '') }} {{ mobile_item('morse', 'Morse', '') }} + {{ mobile_item('ook', 'OOK', '') }} {# Tracking #} {{ mobile_item('adsb', 'Aircraft', '', '/adsb/dashboard') }} {{ mobile_item('ais', 'Vessels', '', '/ais/dashboard') }} diff --git a/utils/ook.py b/utils/ook.py new file mode 100644 index 0000000..c4343ca --- /dev/null +++ b/utils/ook.py @@ -0,0 +1,217 @@ +"""Generic OOK (On-Off Keying) signal decoder utilities. + +Decodes raw OOK frames captured by rtl_433's flex decoder. The flex +decoder handles pulse-width to bit mapping for PWM, PPM, and Manchester +schemes; this layer receives the resulting hex bytes and extracts the +raw bit string so the browser can perform live ASCII interpretation with +configurable bit order. + +Supported modulation schemes (via rtl_433 flex decoder): + - OOK_PWM : Pulse Width Modulation (short=0, long=1) + - OOK_PPM : Pulse Position Modulation (short gap=0, long gap=1) + - OOK_MC_ZEROBIT: Manchester encoding (zero-bit start) + +Usage with rtl_433: + rtl_433 -f 433500000 -R 0 \\ + -X "n=ook,m=OOK_PWM,s=500,l=1500,r=8000,g=5000,t=150,bits>=8" -F json +""" + +from __future__ import annotations + +import json +import logging +import queue +import threading +from datetime import datetime +from typing import Any + +logger = logging.getLogger('intercept.ook') + + +def decode_ook_frame(hex_data: str) -> dict[str, Any] | None: + """Decode an OOK frame from a hex string produced by rtl_433. + + rtl_433's flex decoder already translates pulse timing into bits and + packs them into bytes. This function unpacks those bytes into an + explicit bit string (MSB first) so the browser can re-interpret the + same bits with either byte order on the fly. + + Args: + hex_data: Hex string from the rtl_433 ``codes`` / ``code`` / + ``data`` field, e.g. ``"aa55b248656c6c6f"``. + + Returns: + Dict with ``bits`` (MSB-first bit string), ``hex`` (clean hex), + ``byte_count``, and ``bit_count``, or ``None`` on parse failure. + """ + try: + cleaned = hex_data.replace(' ', '') + # rtl_433 flex decoder prefixes hex with '0x' — strip it + if cleaned.startswith(('0x', '0X')): + cleaned = cleaned[2:] + raw = bytes.fromhex(cleaned) + except ValueError: + return None + + if not raw: + return None + + # Expand bytes to MSB-first bit string + bits = ''.join(f'{b:08b}' for b in raw) + + return { + 'bits': bits, + 'hex': raw.hex(), + 'byte_count': len(raw), + 'bit_count': len(bits), + } + + +def ook_parser_thread( + rtl_stdout, + output_queue: queue.Queue, + stop_event: threading.Event, + encoding: str = 'pwm', + deduplicate: bool = False, +) -> None: + """Thread function: reads rtl_433 JSON output and emits OOK frame events. + + Handles the three rtl_433 hex-output field names (``codes``, ``code``, + ``data``) and, if the initial hex decoding fails, retries with an + inverted bit interpretation. This inversion fallback is only applied + when the primary parse yields no usable hex; it does not attempt to + reinterpret successfully decoded frames that merely swap the short/long + pulse mapping. + + Args: + rtl_stdout: rtl_433 stdout pipe. + output_queue: Queue for SSE events. + stop_event: Threading event to signal shutdown. + encoding: Modulation hint (``'pwm'``, ``'ppm'``, ``'manchester'``). + Informational only — rtl_433 already decoded the bits. + deduplicate: If True, consecutive frames with identical hex are + suppressed; only the first is emitted. + + Events emitted: + type='ook_frame' — decoded frame with bits and hex + type='ook_raw' — raw rtl_433 JSON that contained no code field + type='status' — start/stop notifications + type='error' — error messages + """ + last_hex: str | None = None + + try: + for line in iter(rtl_stdout.readline, b''): + if stop_event.is_set(): + break + + text = line.decode('utf-8', errors='replace').strip() + if not text: + continue + + try: + data = json.loads(text) + except json.JSONDecodeError: + logger.debug(f'[rtl_433/ook] {text}') + continue + + # rtl_433 flex decoder puts hex in 'codes' (list or string), + # 'code' (singular), or 'data' depending on version. + codes = data.get('codes') + if codes is not None: + if isinstance(codes, str): + codes = [codes] if codes else None + + if not codes: + code = data.get('code') + if code: + codes = [str(code)] + + if not codes: + raw_data = data.get('data') + if raw_data: + codes = [str(raw_data)] + + # Extract signal level if rtl_433 was invoked with -M level + rssi: float | None = None + for _rssi_key in ('snr', 'rssi', 'level', 'noise'): + _rssi_val = data.get(_rssi_key) + if _rssi_val is not None: + try: + rssi = round(float(_rssi_val), 1) + except (TypeError, ValueError): + pass + break + + if not codes: + logger.debug( + f'[rtl_433/ook] no code field — keys: {list(data.keys())}' + ) + try: + output_queue.put_nowait({ + 'type': 'ook_raw', + 'data': data, + 'timestamp': datetime.now().strftime('%H:%M:%S'), + }) + except queue.Full: + pass + continue + + for code_hex in codes: + hex_str = str(code_hex).strip() + # Strip leading {N} bit-count prefix if present + if hex_str.startswith('{'): + brace_end = hex_str.find('}') + if brace_end >= 0: + hex_str = hex_str[brace_end + 1:] + + inverted = False + frame = decode_ook_frame(hex_str) + if frame is None: + # Some transmitters use long=0, short=1 (inverted ratio). + try: + inv_bytes = bytes( + b ^ 0xFF + for b in bytes.fromhex(hex_str.replace(' ', '')) + ) + frame = decode_ook_frame(inv_bytes.hex()) + if frame is not None: + inverted = True + except ValueError: + pass + + if frame is None: + continue + + timestamp = datetime.now().strftime('%H:%M:%S') + + # Deduplication: skip if identical to last frame + is_dup = deduplicate and frame['hex'] == last_hex + last_hex = frame['hex'] + + if deduplicate and is_dup: + continue + + try: + event: dict[str, Any] = { + 'type': 'ook_frame', + 'hex': frame['hex'], + 'bits': frame['bits'], + 'byte_count': frame['byte_count'], + 'bit_count': frame['bit_count'], + 'inverted': inverted, + 'encoding': encoding, + 'timestamp': timestamp, + } + if rssi is not None: + event['rssi'] = rssi + output_queue.put_nowait(event) + except queue.Full: + pass + + except Exception as e: + logger.debug(f'OOK parser thread error: {e}') + try: + output_queue.put_nowait({'type': 'error', 'text': str(e)}) + except queue.Full: + pass