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..75258e3 --- /dev/null +++ b/routes/ook.py @@ -0,0 +1,281 @@ +"""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 + 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)) + 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(['-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 + + app_module.ook_queue.put({'type': 'status', 'status': 'started'}) + + 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 + + app_module.ook_queue.put({'type': 'status', 'status': 'stopped'}) + 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/modes/ook.js b/static/js/modes/ook.js new file mode 100644 index 0000000..9fca3ba --- /dev/null +++ b/static/js/modes/ook.js @@ -0,0 +1,358 @@ +/** + * 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 state = { + running: false, + initialized: false, + eventSource: null, + frames: [], // raw frame objects from SSE + frameCount: 0, + bitOrder: 'msb', // 'msb' | 'lsb' + }; + + // ---- Initialization ---- + + function init() { + if (state.initialized) { + checkStatus(); + return; + } + state.initialized = true; + 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(); + } 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++; + + 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)'; + + div.innerHTML = + '' + msg.timestamp + '' + + ' [' + msg.bit_count + 'b]' + + suffix + + '
' + + '' + + 'hex: ' + interp.hex + + '' + + '
' + + '' + + 'ascii: ' + interp.ascii + + ''; + + div.style.cssText = 'font-size:11px; padding: 4px 0; border-bottom: 1px solid #1a1a1a; line-height:1.6;'; + + panel.appendChild(div); + 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'; + } + + function exportLog() { + var lines = ['timestamp,bit_count,hex_msb,ascii_msb,inverted']; + state.frames.forEach(function (msg) { + var interp = interpretBits(msg.bits, 'msb'); + lines.push([ + msg.timestamp, + msg.bit_count, + 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); + } + + // ---- 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; + } + + // ---- 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'; + + var outputPanel = document.getElementById('ookOutputPanel'); + if (outputPanel) outputPanel.style.display = running ? 'block' : 'none'; + } + + // ---- Public API ---- + + return { + init: init, + destroy: destroy, + start: start, + stop: stop, + setFreq: setFreq, + setEncoding: setEncoding, + setBitOrder: setBitOrder, + clearOutput: clearOutput, + exportLog: exportLog, + }; +})(); diff --git a/templates/index.html b/templates/index.html index 0a84bbc..96fb6af 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,29 @@ + + +
@@ -3356,6 +3385,7 @@ + @@ -3515,6 +3545,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); @@ -4363,6 +4394,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 +4494,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 +4537,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 +4557,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) { @@ -4626,6 +4664,8 @@ MeteorScatter.init(); } else if (mode === 'system') { SystemHealth.init(); + } else if (mode === 'ook') { + OokMode.init(); } // Waterfall destroy is now handled by moduleDestroyMap above. diff --git a/templates/partials/modes/ook.html b/templates/partials/modes/ook.html new file mode 100644 index 0000000..98c86a9 --- /dev/null +++ b/templates/partials/modes/ook.html @@ -0,0 +1,132 @@ + +
+
+

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..4f784ac --- /dev/null +++ b/utils/ook.py @@ -0,0 +1,197 @@ +"""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: + raw = bytes.fromhex(hex_data.replace(' ', '')) + 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 falls back to bit-inverted parsing when the primary hex + parse produces no result — needed for transmitters that 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)] + + 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: + output_queue.put_nowait({ + '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, + }) + 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