From 4c282bb05549402dc7f4aa34daaeb8aa9027e13a Mon Sep 17 00:00:00 2001 From: thatsatechnique <28403172+thatsatechnique@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:51:38 -0800 Subject: [PATCH 01/10] feat: add Generic OOK Signal Decoder module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 'OOK Decoder' mode for capturing and decoding arbitrary OOK/ASK signals using rtl_433's flex decoder with fully configurable pulse timing. Covers PWM, PPM, and Manchester encoding schemes. Backend (utils/ook.py, routes/ook.py): - Configurable modulation: OOK_PWM, OOK_PPM, OOK_MC_ZEROBIT - Full rtl_433 flex spec builder with user-supplied pulse timings - Bit-inversion fallback for transmitters with swapped short/long mapping - Optional frame deduplication for repeated transmissions - SSE streaming via /ook/stream Frontend (static/js/modes/ook.js, templates/partials/modes/ook.html): - Live MSB/LSB bit-order toggle — re-renders all stored frames instantly without restarting the decoder - Full-detail frame display: timestamp, bit count, hex, dotted ASCII - Modulation selector buttons with encoding hint text - Full timing grid: short, long, gap/reset, tolerance, min bits - CSV export of captured frames - Global SDR device panel injection (device, SDR type, rtl_tcp, bias-T) Integration (app.py, routes/__init__.py, templates/): - Globals: ook_process, ook_queue, ook_lock - Registered blueprint, nav entries (desktop + mobile), welcome card - ookOutputPanel in visuals area with bit-order toolbar Co-Authored-By: Claude Sonnet 4.6 --- app.py | 5 + routes/__init__.py | 2 + routes/ook.py | 281 +++++++++++++++++++++++ static/js/modes/ook.js | 358 ++++++++++++++++++++++++++++++ templates/index.html | 44 +++- templates/partials/modes/ook.html | 132 +++++++++++ templates/partials/nav.html | 2 + utils/ook.py | 197 ++++++++++++++++ 8 files changed, 1019 insertions(+), 2 deletions(-) create mode 100644 routes/ook.py create mode 100644 static/js/modes/ook.js create mode 100644 templates/partials/modes/ook.html create mode 100644 utils/ook.py 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 From 0c3ccac21c20d66ea60b92c7de45cce68df21849 Mon Sep 17 00:00:00 2001 From: thatsatechnique <28403172+thatsatechnique@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:51:39 -0800 Subject: [PATCH 02/10] feat(ook): add timing presets, RSSI, bit-order suggest, pattern filter, TSCM link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Timing presets: five quick-fill buttons (300/600, 300/900, 400/800, 500/1500, 500 MC) that populate all six pulse-timing fields at once — maps to CTF flag timing profiles - RSSI per frame: add -M level to rtl_433 command; parse snr/rssi/level from JSON; display dB SNR inline with each frame; include rssi_db column in CSV export - Auto bit-order suggest: "Suggest" button counts printable chars across all stored frames for MSB vs LSB, selects the winner, shows count — no decoder restart needed - Pattern filter: live hex/ASCII filter input above the frame log; hides non-matching frames and highlights matches in green; respects current bit order - TSCM integration: "Decode (OOK)" button in RF signal device details panel switches to OOK mode and pre-fills frequency — frontend-only, no backend changes needed Co-Authored-By: Claude Sonnet 4.6 --- routes/ook.py | 2 +- static/js/modes/ook.js | 99 ++++++++++++++++++++++++++++++- templates/index.html | 31 +++++++++- templates/partials/modes/ook.html | 15 +++++ utils/ook.py | 18 +++++- 5 files changed, 157 insertions(+), 8 deletions(-) diff --git a/routes/ook.py b/routes/ook.py index 75258e3..2fb6bdb 100644 --- a/routes/ook.py +++ b/routes/ook.py @@ -152,7 +152,7 @@ def start_ook() -> Response: continue filtered_cmd.append(arg) - filtered_cmd.extend(['-R', '0', '-X', flex_spec]) + filtered_cmd.extend(['-M', 'level', '-R', '0', '-X', flex_spec]) full_cmd = ' '.join(filtered_cmd) logger.info(f'OOK decoder running: {full_cmd}') diff --git a/static/js/modes/ook.js b/static/js/modes/ook.js index 9fca3ba..45a6da8 100644 --- a/static/js/modes/ook.js +++ b/static/js/modes/ook.js @@ -16,6 +16,7 @@ var OokMode = (function () { frames: [], // raw frame objects from SSE frameCount: 0, bitOrder: 'msb', // 'msb' | 'lsb' + filterQuery: '', // active hex/ascii filter }; // ---- Initialization ---- @@ -209,7 +210,7 @@ var OokMode = (function () { var div = document.createElement('div'); div.className = 'ook-frame'; - div.dataset.bits = msg.bits; + div.dataset.bits = msg.bits || ''; div.dataset.bitCount = msg.bit_count; div.dataset.inverted = msg.inverted ? '1' : '0'; @@ -217,10 +218,14 @@ var OokMode = (function () { 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]' + - suffix + + rssiStr + suffix + '
' + '' + 'hex: ' + interp.hex + @@ -232,6 +237,16 @@ var OokMode = (function () { 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); panel.scrollTop = panel.scrollHeight; } @@ -272,12 +287,13 @@ var OokMode = (function () { } function exportLog() { - var lines = ['timestamp,bit_count,hex_msb,ascii_msb,inverted']; + 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, @@ -325,6 +341,80 @@ var OokMode = (function () { if (el) el.value = mhz; } + /** + * 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) { @@ -351,7 +441,10 @@ var OokMode = (function () { stop: stop, setFreq: setFreq, setEncoding: setEncoding, + setTiming: setTiming, setBitOrder: setBitOrder, + suggestBitOrder: suggestBitOrder, + filterFrames: filterFrames, clearOutput: clearOutput, exportLog: exportLog, }; diff --git a/templates/index.html b/templates/index.html index 96fb6af..467c4ed 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3294,6 +3294,7 @@ +
+ +
+ + + + + +
+
diff --git a/utils/ook.py b/utils/ook.py index 4f784ac..8374562 100644 --- a/utils/ook.py +++ b/utils/ook.py @@ -126,6 +126,17 @@ def ook_parser_thread( 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())}' @@ -176,7 +187,7 @@ def ook_parser_thread( continue try: - output_queue.put_nowait({ + event: dict[str, Any] = { 'type': 'ook_frame', 'hex': frame['hex'], 'bits': frame['bits'], @@ -185,7 +196,10 @@ def ook_parser_thread( 'inverted': inverted, 'encoding': encoding, 'timestamp': timestamp, - }) + } + if rssi is not None: + event['rssi'] = rssi + output_queue.put_nowait(event) except queue.Full: pass From f771100a4cf6612559221a8a2dd6af6cfa1ff455 Mon Sep 17 00:00:00 2001 From: thatsatechnique <28403172+thatsatechnique@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:51:39 -0800 Subject: [PATCH 03/10] fix(ook): fix output panel layout, persist frames, wire global status bar - Fix double-scroll by switching ookOutputPanel to flex layout - Keep decoded frames visible after stopping (persist for review) - Wire global Clear/CSV/JSON status bar buttons to OOK functions - Hide default output pane in OOK mode (uses own panel) - Add command display showing the active rtl_433 command - Add JSON export and auto-scroll support - Fix 0x prefix stripping in OOK hex decoder - Fix PWM encoding hint text Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 ++ static/js/modes/ook.js | 63 +++++++++++++++++++++++++++++-- templates/index.html | 20 +++++----- templates/partials/modes/ook.html | 10 ++++- utils/ook.py | 6 ++- 5 files changed, 86 insertions(+), 16 deletions(-) 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/static/js/modes/ook.js b/static/js/modes/ook.js index 45a6da8..2c3c339 100644 --- a/static/js/modes/ook.js +++ b/static/js/modes/ook.js @@ -17,6 +17,7 @@ var OokMode = (function () { frameCount: 0, bitOrder: 'msb', // 'msb' | 'lsb' filterQuery: '', // active hex/ascii filter + command: '', // the rtl_433 command being run }; // ---- Initialization ---- @@ -95,6 +96,7 @@ var OokMode = (function () { updateUI(true); connectSSE(); clearOutput(); + showCommand(data.command || ''); } else { alert('Error: ' + (data.message || 'Unknown error')); } @@ -248,7 +250,9 @@ var OokMode = (function () { } panel.appendChild(div); - panel.scrollTop = panel.scrollHeight; + if (typeof autoScroll === 'undefined' || autoScroll) { + panel.scrollTop = panel.scrollHeight; + } } // ---- Bit order toggle ---- @@ -284,6 +288,12 @@ var OokMode = (function () { 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() { @@ -308,6 +318,47 @@ var OokMode = (function () { 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 || 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) { @@ -328,7 +379,7 @@ var OokMode = (function () { // Update timing hint var hints = { - pwm: 'Short pulse = 0, long pulse = 1. Most common for ISM OOK.', + pwm: 'Short pulse = 1, long pulse = 0. Most common for ISM OOK.', ppm: 'Short gap = 0, long gap = 1. Pulse position encoding.', manchester: 'Rising edge = 1, falling edge = 0. Self-clocking.', }; @@ -428,8 +479,12 @@ var OokMode = (function () { 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) outputPanel.style.display = running ? 'block' : 'none'; + if (outputPanel) { + var showPanel = running || state.frames.length > 0; + outputPanel.style.display = showPanel ? 'flex' : 'none'; + } } // ---- Public API ---- @@ -447,5 +502,7 @@ var OokMode = (function () { filterFrames: filterFrames, clearOutput: clearOutput, exportLog: exportLog, + exportJSON: exportJSON, + copyCommand: copyCommand, }; })(); diff --git a/templates/index.html b/templates/index.html index 467c4ed..cfbd143 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3292,11 +3292,11 @@
- @@ -140,6 +140,14 @@ 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/utils/ook.py b/utils/ook.py index 8374562..f82cdd5 100644 --- a/utils/ook.py +++ b/utils/ook.py @@ -45,7 +45,11 @@ def decode_ook_frame(hex_data: str) -> dict[str, Any] | None: ``byte_count``, and ``bit_count``, or ``None`` on parse failure. """ try: - raw = bytes.fromhex(hex_data.replace(' ', '')) + 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 From b4757b158905b1816143d14b42a6cf480eee456c Mon Sep 17 00:00:00 2001 From: thatsatechnique <28403172+thatsatechnique@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:08:32 -0800 Subject: [PATCH 04/10] feat(ook): add cheat sheet with modulation and timing guide Covers identifying modulation type (PWM/PPM/Manchester), finding pulse timing via rtl_433 -A, common ISM frequencies and timings, and troubleshooting tips for tolerance and bit order. Co-Authored-By: Claude Opus 4.6 --- static/js/core/cheat-sheets.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/static/js/core/cheat-sheets.js b/static/js/core/cheat-sheets.js index f223079..f758078 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=1, long=0), 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) { From cde24642ac9a426e31204df5ee56f09708c9d982 Mon Sep 17 00:00:00 2001 From: thatsatechnique <28403172+thatsatechnique@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:13:22 -0800 Subject: [PATCH 05/10] feat(ook): add persistent frequency presets with add/remove/reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded frequency buttons with localStorage-backed presets. Default presets are standard ISM frequencies (433.920, 315, 868, 915 MHz). Users can add custom frequencies, right-click to remove, and reset to defaults — matching the pager module pattern. Co-Authored-By: Claude Opus 4.6 --- static/js/modes/ook.js | 59 +++++++++++++++++++++++++++++++ templates/partials/modes/ook.html | 20 +++++------ 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/static/js/modes/ook.js b/static/js/modes/ook.js index 2c3c339..e48edb9 100644 --- a/static/js/modes/ook.js +++ b/static/js/modes/ook.js @@ -9,6 +9,8 @@ var OokMode = (function () { 'use strict'; + var DEFAULT_FREQ_PRESETS = ['433.920', '315.000', '868.000', '915.000']; + var state = { running: false, initialized: false, @@ -28,6 +30,7 @@ var OokMode = (function () { return; } state.initialized = true; + renderPresets(); checkStatus(); } @@ -392,6 +395,58 @@ var OokMode = (function () { 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) @@ -495,6 +550,10 @@ var OokMode = (function () { start: start, stop: stop, setFreq: setFreq, + addPreset: addPreset, + removePreset: removePreset, + resetPresets: resetPresets, + renderPresets: renderPresets, setEncoding: setEncoding, setTiming: setTiming, setBitOrder: setBitOrder, diff --git a/templates/partials/modes/ook.html b/templates/partials/modes/ook.html index f99169b..d28d995 100644 --- a/templates/partials/modes/ook.html +++ b/templates/partials/modes/ook.html @@ -16,17 +16,15 @@
- -
- - - - - - - - - + +
+ +
+
+ + +
From 93fb694e25068ec9c71f5fdf2e8abd4e9dbd6a12 Mon Sep 17 00:00:00 2001 From: thatsatechnique <28403172+thatsatechnique@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:25:15 -0800 Subject: [PATCH 06/10] fix(ook): address code review findings from Copilot PR review - Fix XSS: escape ASCII output in innerHTML via escapeHtml() - Fix deadlock: use put_nowait() for queue ops under ook_lock - Fix SSE leak: add ook to moduleDestroyMap so switching modes closes the EventSource - Fix RSSI: explicit null check preserves valid zero values in JSON export - Add frame cap: trim oldest frames at 5000 to prevent unbounded memory growth on busy bands - Validate timing params: wrap int() casts in try/except, return 400 instead of 500 on invalid input - Fix PWM hint: correct to short=0/long=1 matching rtl_433 OOK_PWM convention (UI, JS hints, and cheat sheet) - Fix inversion docstring: clarify fallback only applies when primary hex parse fails, not for valid decoded frames Co-Authored-By: Claude Opus 4.6 --- routes/ook.py | 25 +++++++++++++++++-------- static/js/core/cheat-sheets.js | 2 +- static/js/modes/ook.js | 14 +++++++++++--- templates/index.html | 1 + templates/partials/modes/ook.html | 2 +- utils/ook.py | 8 +++++--- 6 files changed, 36 insertions(+), 16 deletions(-) diff --git a/routes/ook.py b/routes/ook.py index 2fb6bdb..87c209d 100644 --- a/routes/ook.py +++ b/routes/ook.py @@ -75,12 +75,15 @@ def start_ook() -> Response: 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)) + 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 @@ -195,7 +198,10 @@ def start_ook() -> Response: 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'}) + 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', @@ -244,7 +250,10 @@ def stop_ook() -> Response: app_module.release_sdr_device(ook_active_device) ook_active_device = None - app_module.ook_queue.put({'type': 'status', 'status': 'stopped'}) + 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'}) diff --git a/static/js/core/cheat-sheets.js b/static/js/core/cheat-sheets.js index f758078..bed4336 100644 --- a/static/js/core/cheat-sheets.js +++ b/static/js/core/cheat-sheets.js @@ -34,7 +34,7 @@ const CheatSheets = (function () { 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=1, long=0), gaps constant — most common for ISM remotes/sensors. PPM: pulses constant, gap widths encode data. Manchester: self-clocking, equal-width pulses, data in transitions.', + '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).', diff --git a/static/js/modes/ook.js b/static/js/modes/ook.js index e48edb9..8221f4a 100644 --- a/static/js/modes/ook.js +++ b/static/js/modes/ook.js @@ -10,6 +10,7 @@ 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, @@ -162,6 +163,13 @@ var OokMode = (function () { 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'); @@ -237,7 +245,7 @@ var OokMode = (function () { '
' + '
' + '' + - 'ascii: ' + interp.ascii + + '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;'; @@ -328,7 +336,7 @@ var OokMode = (function () { return { timestamp: msg.timestamp, bit_count: msg.bit_count, - rssi: msg.rssi || null, + rssi: (msg.rssi !== undefined && msg.rssi !== null) ? msg.rssi : null, hex: interp.hex, ascii: interp.ascii, inverted: msg.inverted, @@ -382,7 +390,7 @@ var OokMode = (function () { // Update timing hint var hints = { - pwm: 'Short pulse = 1, long pulse = 0. Most common for ISM OOK.', + 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.', }; diff --git a/templates/index.html b/templates/index.html index cfbd143..8538606 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4107,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; } diff --git a/templates/partials/modes/ook.html b/templates/partials/modes/ook.html index d28d995..eb2919d 100644 --- a/templates/partials/modes/ook.html +++ b/templates/partials/modes/ook.html @@ -57,7 +57,7 @@

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

diff --git a/utils/ook.py b/utils/ook.py index f82cdd5..c4343ca 100644 --- a/utils/ook.py +++ b/utils/ook.py @@ -77,9 +77,11 @@ def ook_parser_thread( """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. + ``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. From 18db66bce327819dea9c0920f0e697cf71736363 Mon Sep 17 00:00:00 2001 From: thatsatechnique <28403172+thatsatechnique@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:52:32 -0800 Subject: [PATCH 07/10] =?UTF-8?q?fix(ook):=20harden=20for=20upstream=20rev?= =?UTF-8?q?iew=20=E2=80=94=20tests,=20cleanup,=20CSS=20extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add kill_all() handler for OOK process cleanup on global reset - Fix stop_ook() to close pipes and join parser thread (prevents hangs) - Add ook.css with CSS classes, replace inline styles in ook.html - Register ook.css in lazy-load style map (INTERCEPT_MODE_STYLE_MAP) - Fix frontend frequency min=24 to match backend validation - Add 22 unit tests for decode_ook_frame, ook_parser_thread, and routes Co-Authored-By: Claude Opus 4.6 --- app.py | 9 +- routes/ook.py | 25 ++- static/css/modes/ook.css | 110 +++++++++++++ templates/index.html | 3 +- templates/partials/modes/ook.html | 41 +++-- tests/test_ook.py | 252 ++++++++++++++++++++++++++++++ 6 files changed, 413 insertions(+), 27 deletions(-) create mode 100644 static/css/modes/ook.css create mode 100644 tests/test_ook.py diff --git a/app.py b/app.py index 420f8a5..0c2c7f0 100644 --- a/app.py +++ b/app.py @@ -832,7 +832,7 @@ def health_check() -> Response: def kill_all() -> Response: """Kill all decoder, WiFi, and Bluetooth processes.""" global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process - global vdl2_process, morse_process, radiosonde_process + global vdl2_process, morse_process, radiosonde_process, ook_process global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process # Import modules to reset their state @@ -896,6 +896,13 @@ def kill_all() -> Response: with morse_lock: morse_process = None + # Reset OOK state + with ook_lock: + if ook_process: + safe_terminate(ook_process) + unregister_process(ook_process) + ook_process = None + # Reset APRS state with aprs_lock: aprs_process = None diff --git a/routes/ook.py b/routes/ook.py index 87c209d..acedd0a 100644 --- a/routes/ook.py +++ b/routes/ook.py @@ -232,20 +232,39 @@ def start_ook() -> Response: return jsonify({'status': 'error', 'message': str(e)}), 500 +def _close_pipe(pipe_obj) -> None: + """Close a subprocess pipe, suppressing errors.""" + if pipe_obj is not None: + with contextlib.suppress(Exception): + pipe_obj.close() + + @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) + proc = app_module.ook_process + stop_event = getattr(proc, '_stop_parser', None) + parser_thread = getattr(proc, '_parser_thread', None) + + # Signal parser thread to stop if stop_event: stop_event.set() - safe_terminate(app_module.ook_process) - unregister_process(app_module.ook_process) + # Close pipes so parser thread unblocks from readline() + _close_pipe(getattr(proc, 'stdout', None)) + _close_pipe(getattr(proc, 'stderr', None)) + + safe_terminate(proc) + unregister_process(proc) app_module.ook_process = None + # Join parser thread with timeout + if parser_thread: + parser_thread.join(timeout=0.5) + if ook_active_device is not None: app_module.release_sdr_device(ook_active_device) ook_active_device = None diff --git a/static/css/modes/ook.css b/static/css/modes/ook.css new file mode 100644 index 0000000..beb0489 --- /dev/null +++ b/static/css/modes/ook.css @@ -0,0 +1,110 @@ +/* OOK Signal Decoder Styles */ + +.ook-presets { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.ook-preset-add { + margin-top: 6px; + display: flex; + gap: 4px; +} + +.ook-preset-add input { + width: 80px; + background: var(--bg-tertiary, #111); + border: 1px solid var(--border-color, #222); + border-radius: 3px; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 11px; + padding: 3px 6px; +} + +.ook-preset-hint { + font-size: 9px; + color: var(--text-muted, #555); +} + +.ook-encoding-btns { + display: flex; + gap: 4px; +} + +.ook-encoding-btns .preset-btn { + flex: 1; +} + +.ook-timing-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.ook-timing-presets { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +.ook-status-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-dim); +} + +.ook-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-dim); +} + +.ook-warning { + font-size: 11px; + color: #ffaa00; + line-height: 1.5; +} + +.ook-command-display { + display: none; + margin-top: 8px; +} + +.ook-command-label { + font-size: 10px; + color: var(--text-muted, #555); + text-transform: uppercase; + letter-spacing: 1px; +} + +.ook-command-text { + margin: 0; + padding: 6px 8px; + background: var(--bg-deep, #0a0a0a); + border: 1px solid var(--border-color, #1a2e1a); + border-radius: 4px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim); + white-space: pre-wrap; + word-break: break-all; + line-height: 1.5; +} + +.ook-dedup-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.ook-dedup-label input[type="checkbox"] { + width: auto; + margin: 0; +} diff --git a/templates/index.html b/templates/index.html index 8538606..4545891 100644 --- a/templates/index.html +++ b/templates/index.html @@ -86,7 +86,8 @@ morse: "{{ url_for('static', filename='css/modes/morse.css') }}", radiosonde: "{{ url_for('static', filename='css/modes/radiosonde.css') }}", meteor: "{{ url_for('static', filename='css/modes/meteor.css') }}", - system: "{{ url_for('static', filename='css/modes/system.css') }}" + system: "{{ url_for('static', filename='css/modes/system.css') }}", + ook: "{{ url_for('static', filename='css/modes/ook.css') }}" }; window.INTERCEPT_MODE_STYLE_LOADED = {}; window.INTERCEPT_MODE_STYLE_PROMISES = {}; diff --git a/templates/partials/modes/ook.html b/templates/partials/modes/ook.html index eb2919d..228b107 100644 --- a/templates/partials/modes/ook.html +++ b/templates/partials/modes/ook.html @@ -13,16 +13,15 @@

Frequency

- +
- -
+ +
-
- +
+
@@ -44,16 +43,14 @@

Modulation

-
+
+ style="background: var(--accent); color: #000;">PWM + onclick="OokMode.setEncoding('ppm')">PPM + onclick="OokMode.setEncoding('manchester')">Manchester

@@ -67,7 +64,7 @@

Pulse widths in microseconds for the flex decoder.

-
+
@@ -95,7 +92,7 @@
-
+
-

+            

         
diff --git a/tests/test_ook.py b/tests/test_ook.py new file mode 100644 index 0000000..4026c5b --- /dev/null +++ b/tests/test_ook.py @@ -0,0 +1,252 @@ +"""Tests for OOK signal decoder utilities and route handlers.""" + +from __future__ import annotations + +import io +import json +import queue +import threading + +import pytest + +from utils.ook import decode_ook_frame, ook_parser_thread + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _login_session(client) -> None: + """Mark the Flask test session as authenticated.""" + with client.session_transaction() as sess: + sess['logged_in'] = True + sess['username'] = 'test' + sess['role'] = 'admin' + + +# --------------------------------------------------------------------------- +# decode_ook_frame +# --------------------------------------------------------------------------- + +class TestDecodeOokFrame: + def test_valid_hex_returns_bits_and_hex(self): + result = decode_ook_frame('aa55') + assert result is not None + assert result['hex'] == 'aa55' + assert result['bits'] == '1010101001010101' + assert result['byte_count'] == 2 + assert result['bit_count'] == 16 + + def test_strips_0x_prefix(self): + result = decode_ook_frame('0xaa55') + assert result is not None + assert result['hex'] == 'aa55' + + def test_strips_0X_uppercase_prefix(self): + result = decode_ook_frame('0Xff') + assert result is not None + assert result['hex'] == 'ff' + assert result['bits'] == '11111111' + + def test_strips_spaces(self): + result = decode_ook_frame('aa 55') + assert result is not None + assert result['hex'] == 'aa55' + + def test_invalid_hex_returns_none(self): + assert decode_ook_frame('zzzz') is None + + def test_empty_string_returns_none(self): + assert decode_ook_frame('') is None + + def test_just_0x_prefix_returns_none(self): + assert decode_ook_frame('0x') is None + + def test_single_byte(self): + result = decode_ook_frame('48') + assert result is not None + assert result['bits'] == '01001000' + assert result['byte_count'] == 1 + + def test_hello_ascii(self): + """'Hello' in hex is 48656c6c6f.""" + result = decode_ook_frame('48656c6c6f') + assert result is not None + assert result['hex'] == '48656c6c6f' + assert result['byte_count'] == 5 + assert result['bit_count'] == 40 + + +# --------------------------------------------------------------------------- +# ook_parser_thread +# --------------------------------------------------------------------------- + +class TestOokParserThread: + def _run_parser(self, json_lines, encoding='pwm', deduplicate=False): + """Feed JSON lines to parser thread and collect output events.""" + raw = '\n'.join(json.dumps(line) for line in json_lines) + '\n' + stdout = io.BytesIO(raw.encode('utf-8')) + output_queue = queue.Queue() + stop_event = threading.Event() + + t = threading.Thread( + target=ook_parser_thread, + args=(stdout, output_queue, stop_event, encoding, deduplicate), + ) + t.start() + t.join(timeout=2) + + events = [] + while not output_queue.empty(): + events.append(output_queue.get_nowait()) + return events + + def test_parses_codes_field_list(self): + events = self._run_parser([{'codes': ['aa55']}]) + frames = [e for e in events if e.get('type') == 'ook_frame'] + assert len(frames) == 1 + assert frames[0]['hex'] == 'aa55' + assert frames[0]['bits'] == '1010101001010101' + assert frames[0]['inverted'] is False + + def test_parses_codes_field_string(self): + events = self._run_parser([{'codes': 'ff00'}]) + frames = [e for e in events if e.get('type') == 'ook_frame'] + assert len(frames) == 1 + assert frames[0]['hex'] == 'ff00' + + def test_parses_code_field(self): + events = self._run_parser([{'code': 'abcd'}]) + frames = [e for e in events if e.get('type') == 'ook_frame'] + assert len(frames) == 1 + assert frames[0]['hex'] == 'abcd' + + def test_parses_data_field(self): + events = self._run_parser([{'data': '1234'}]) + frames = [e for e in events if e.get('type') == 'ook_frame'] + assert len(frames) == 1 + assert frames[0]['hex'] == '1234' + + def test_strips_brace_bit_count_prefix(self): + """rtl_433 sometimes prefixes with {N} bit count.""" + events = self._run_parser([{'codes': ['{16}aa55']}]) + frames = [e for e in events if e.get('type') == 'ook_frame'] + assert len(frames) == 1 + assert frames[0]['hex'] == 'aa55' + + def test_deduplication_suppresses_consecutive_identical(self): + events = self._run_parser( + [{'codes': ['aa55']}, {'codes': ['aa55']}, {'codes': ['aa55']}], + deduplicate=True, + ) + frames = [e for e in events if e.get('type') == 'ook_frame'] + assert len(frames) == 1 + + def test_deduplication_allows_different_frames(self): + events = self._run_parser( + [{'codes': ['aa55']}, {'codes': ['ff00']}, {'codes': ['aa55']}], + deduplicate=True, + ) + frames = [e for e in events if e.get('type') == 'ook_frame'] + assert len(frames) == 3 + + def test_no_code_field_emits_ook_raw(self): + events = self._run_parser([{'model': 'unknown', 'id': 42}]) + raw_events = [e for e in events if e.get('type') == 'ook_raw'] + assert len(raw_events) == 1 + + def test_rssi_extracted_from_snr(self): + events = self._run_parser([{'codes': ['aa55'], 'snr': 12.3}]) + frames = [e for e in events if e.get('type') == 'ook_frame'] + assert len(frames) == 1 + assert frames[0]['rssi'] == 12.3 + + def test_encoding_passed_through(self): + events = self._run_parser([{'codes': ['aa55']}], encoding='manchester') + frames = [e for e in events if e.get('type') == 'ook_frame'] + assert frames[0]['encoding'] == 'manchester' + + def test_timestamp_present(self): + events = self._run_parser([{'codes': ['aa55']}]) + frames = [e for e in events if e.get('type') == 'ook_frame'] + assert 'timestamp' in frames[0] + assert len(frames[0]['timestamp']) > 0 + + def test_invalid_json_skipped(self): + """Non-JSON lines should be silently skipped.""" + raw = b'not json\n{"codes": ["aa55"]}\n' + stdout = io.BytesIO(raw) + output_queue = queue.Queue() + stop_event = threading.Event() + + t = threading.Thread( + target=ook_parser_thread, + args=(stdout, output_queue, stop_event), + ) + t.start() + t.join(timeout=2) + + events = [] + while not output_queue.empty(): + events.append(output_queue.get_nowait()) + frames = [e for e in events if e.get('type') == 'ook_frame'] + assert len(frames) == 1 + + +# --------------------------------------------------------------------------- +# Route handlers +# --------------------------------------------------------------------------- + +class TestOokRoutes: + @pytest.fixture + def client(self): + import app as app_module + from routes import register_blueprints + + app_module.app.config['TESTING'] = True + if 'ook' not in app_module.app.blueprints: + register_blueprints(app_module.app) + with app_module.app.test_client() as c: + yield c + + def test_status_returns_not_running(self, client): + _login_session(client) + resp = client.get('/ook/status') + assert resp.status_code == 200 + data = resp.get_json() + assert data['running'] is False + + def test_stop_when_not_running(self, client): + _login_session(client) + resp = client.post('/ook/stop') + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'not_running' + + def test_start_validates_frequency(self, client): + _login_session(client) + resp = client.post('/ook/start', + json={'frequency': 'invalid'}, + content_type='application/json') + assert resp.status_code == 400 + + def test_start_validates_encoding(self, client): + _login_session(client) + resp = client.post('/ook/start', + json={'encoding': 'invalid_enc'}, + content_type='application/json') + assert resp.status_code == 400 + + def test_start_validates_timing_params(self, client): + _login_session(client) + resp = client.post('/ook/start', + json={'short_pulse': 'not_a_number'}, + content_type='application/json') + assert resp.status_code == 400 + + def test_start_rejects_negative_frequency(self, client): + _login_session(client) + resp = client.post('/ook/start', + json={'frequency': '-5'}, + content_type='application/json') + assert resp.status_code == 400 From 7b4ad20805a31b7fee81e0e24810034af423345d Mon Sep 17 00:00:00 2001 From: thatsatechnique <28403172+thatsatechnique@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:32:31 -0800 Subject: [PATCH 08/10] =?UTF-8?q?fix(ook):=20address=20upstream=20PR=20rev?= =?UTF-8?q?iew=20=E2=80=94=20SDR=20tracking,=20validation,=20cleanup,=20XS?= =?UTF-8?q?S?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: - Pass sdr_type_str to claim/release_sdr_device (was missing 3rd arg) - Add ook_active_sdr_type module-level var for proper device registry tracking - Add server-side range validation on all timing params via validate_positive_int Major: - Extract cleanup_ook() function for full teardown (stop_event, pipes, process, SDR release) — called from both stop_ook() and kill_all() - Replace Popen monkey-patching with module-level _ook_stop_event/_ook_parser_thread - Fix XSS: define local _esc() fallback in ook.js, never use raw innerHTML - Remove dead inversion code path in utils/ook.py (bytes.fromhex on same string that already failed decode — could never produce a result) Minor: - Status event key 'status' → 'text' for consistency with other modules - Parser thread logging: debug → warning for missing code field and errors - Parser thread emits status:stopped on exit (normal EOF or crash) - Add cache-busting ?v={{ version }}&r=ook1 to ook.js script include - Fix gain/ppm comparison: != '0' (string) → != 0 (number) Tests: 22 → 33 (added start success, stop with process, SSE stream, timing range validation, stopped-on-exit event) Co-Authored-By: Claude Opus 4.6 --- app.py | 14 +++-- routes/ook.py | 137 +++++++++++++++++++++++++---------------- static/js/modes/ook.js | 11 +++- templates/index.html | 2 +- tests/test_ook.py | 111 ++++++++++++++++++++++++++++++++- utils/ook.py | 34 ++++------ 6 files changed, 224 insertions(+), 85 deletions(-) diff --git a/app.py b/app.py index 0c2c7f0..f87ff79 100644 --- a/app.py +++ b/app.py @@ -896,12 +896,16 @@ def kill_all() -> Response: with morse_lock: morse_process = None - # Reset OOK state + # Reset OOK state (full cleanup: parser thread, pipes, SDR release) with ook_lock: - if ook_process: - safe_terminate(ook_process) - unregister_process(ook_process) - ook_process = None + try: + from routes.ook import cleanup_ook + cleanup_ook(emit_status=False) + except Exception: + if ook_process: + safe_terminate(ook_process) + unregister_process(ook_process) + ook_process = None # Reset APRS state with aprs_lock: diff --git a/routes/ook.py b/routes/ook.py index acedd0a..ddefde8 100644 --- a/routes/ook.py +++ b/routes/ook.py @@ -26,6 +26,7 @@ from utils.validation import ( validate_device_index, validate_frequency, validate_gain, + validate_positive_int, validate_ppm, validate_rtl_tcp_host, validate_rtl_tcp_port, @@ -33,8 +34,13 @@ from utils.validation import ( ook_bp = Blueprint('ook', __name__) -# Track which device is being used +# Track which device / SDR type is being used ook_active_device: int | None = None +ook_active_sdr_type: str | None = None + +# Parser thread state (avoids monkey-patching subprocess.Popen) +_ook_stop_event: threading.Event | None = None +_ook_parser_thread: threading.Thread | None = None # Supported modulation schemes → rtl_433 flex decoder modulation string _MODULATION_MAP = { @@ -53,7 +59,7 @@ def _validate_encoding(value: Any) -> str: @ook_bp.route('/ook/start', methods=['POST']) def start_ook() -> Response: - global ook_active_device + global ook_active_device, ook_active_sdr_type, _ook_stop_event, _ook_parser_thread with app_module.ook_lock: if app_module.ook_process: @@ -74,24 +80,36 @@ def start_ook() -> Response: except ValueError as e: return jsonify({'status': 'error', 'message': str(e)}), 400 - # OOK flex decoder timing parameters + # OOK flex decoder timing parameters (server-side range validation) 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: + short_pulse = validate_positive_int(data.get('short_pulse', 300), 'short_pulse', max_val=100000) + long_pulse = validate_positive_int(data.get('long_pulse', 600), 'long_pulse', max_val=100000) + reset_limit = validate_positive_int(data.get('reset_limit', 8000), 'reset_limit', max_val=1000000) + gap_limit = validate_positive_int(data.get('gap_limit', 5000), 'gap_limit', max_val=1000000) + tolerance = validate_positive_int(data.get('tolerance', 150), 'tolerance', max_val=50000) + min_bits = validate_positive_int(data.get('min_bits', 8), 'min_bits', max_val=4096) + except ValueError as e: return jsonify({'status': 'error', 'message': f'Invalid timing parameter: {e}'}), 400 + if min_bits < 1: + return jsonify({'status': 'error', 'message': 'min_bits must be >= 1'}), 400 + if short_pulse < 1 or long_pulse < 1: + return jsonify({'status': 'error', 'message': 'Pulse widths must be >= 1'}), 400 deduplicate = bool(data.get('deduplicate', False)) + # Parse SDR type early — needed for device claim + sdr_type_str = data.get('sdr_type', 'rtlsdr') + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR + sdr_type_str = 'rtlsdr' + 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') + error = app_module.claim_sdr_device(device_int, 'ook', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -99,6 +117,7 @@ def start_ook() -> Response: 'message': error, }), 409 ook_active_device = device_int + ook_active_sdr_type = sdr_type_str while not app_module.ook_queue.empty(): try: @@ -106,12 +125,6 @@ def start_ook() -> Response: 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) @@ -130,8 +143,8 @@ def start_ook() -> Response: 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, + gain=float(gain) if gain and gain != 0 else None, + ppm=int(ppm) if ppm and ppm != 0 else None, bias_t=bias_t, ) @@ -195,11 +208,11 @@ def start_ook() -> Response: 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 + _ook_stop_event = stop_event + _ook_parser_thread = parser_thread try: - app_module.ook_queue.put_nowait({'type': 'status', 'status': 'started'}) + app_module.ook_queue.put_nowait({'type': 'status', 'text': 'started'}) except queue.Full: logger.warning("OOK 'started' status dropped — queue full") @@ -214,8 +227,9 @@ def start_ook() -> Response: except FileNotFoundError as e: if ook_active_device is not None: - app_module.release_sdr_device(ook_active_device) + app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr') ook_active_device = None + ook_active_sdr_type = None return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}), 400 except Exception as e: @@ -227,8 +241,9 @@ def start_ook() -> Response: rtl_process.kill() unregister_process(rtl_process) if ook_active_device is not None: - app_module.release_sdr_device(ook_active_device) + app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr') ook_active_device = None + ook_active_sdr_type = None return jsonify({'status': 'error', 'message': str(e)}), 500 @@ -239,40 +254,54 @@ def _close_pipe(pipe_obj) -> None: pipe_obj.close() +def cleanup_ook(*, emit_status: bool = True) -> None: + """Full OOK cleanup: stop parser, terminate process, release SDR device. + + Safe to call from ``stop_ook()`` and ``kill_all()``. Caller must hold + ``app_module.ook_lock``. + """ + global ook_active_device, ook_active_sdr_type, _ook_stop_event, _ook_parser_thread + + proc = app_module.ook_process + if not proc: + return + + # Signal parser thread to stop + if _ook_stop_event: + _ook_stop_event.set() + + # Close pipes so parser thread unblocks from readline() + _close_pipe(getattr(proc, 'stdout', None)) + _close_pipe(getattr(proc, 'stderr', None)) + + safe_terminate(proc) + unregister_process(proc) + app_module.ook_process = None + + # Join parser thread with timeout + if _ook_parser_thread: + _ook_parser_thread.join(timeout=0.5) + + _ook_stop_event = None + _ook_parser_thread = None + + if ook_active_device is not None: + app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr') + ook_active_device = None + ook_active_sdr_type = None + + if emit_status: + try: + app_module.ook_queue.put_nowait({'type': 'status', 'text': 'stopped'}) + except queue.Full: + logger.warning("OOK 'stopped' status dropped — queue full") + + @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: - proc = app_module.ook_process - stop_event = getattr(proc, '_stop_parser', None) - parser_thread = getattr(proc, '_parser_thread', None) - - # Signal parser thread to stop - if stop_event: - stop_event.set() - - # Close pipes so parser thread unblocks from readline() - _close_pipe(getattr(proc, 'stdout', None)) - _close_pipe(getattr(proc, 'stderr', None)) - - safe_terminate(proc) - unregister_process(proc) - app_module.ook_process = None - - # Join parser thread with timeout - if parser_thread: - parser_thread.join(timeout=0.5) - - 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") + cleanup_ook() return jsonify({'status': 'stopped'}) return jsonify({'status': 'not_running'}) diff --git a/static/js/modes/ook.js b/static/js/modes/ook.js index 8221f4a..60100fd 100644 --- a/static/js/modes/ook.js +++ b/static/js/modes/ook.js @@ -12,6 +12,13 @@ var OokMode = (function () { var DEFAULT_FREQ_PRESETS = ['433.920', '315.000', '868.000', '915.000']; var MAX_FRAMES = 5000; + // Local XSS-safe escape — never fall back to raw innerHTML + var _esc = typeof escapeHtml === 'function' ? escapeHtml : function (s) { + var d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + }; + var state = { running: false, initialized: false, @@ -147,7 +154,7 @@ var OokMode = (function () { if (msg.type === 'ook_frame') { handleFrame(msg); } else if (msg.type === 'status') { - if (msg.status === 'stopped') { + if (msg.text === 'stopped') { state.running = false; updateUI(false); disconnectSSE(); @@ -245,7 +252,7 @@ var OokMode = (function () { '' + '
' + '' + - 'ascii: ' + (typeof escapeHtml === 'function' ? escapeHtml(interp.ascii) : interp.ascii) + + 'ascii: ' + _esc(interp.ascii) + ''; div.style.cssText = 'font-size:11px; padding: 4px 0; border-bottom: 1px solid #1a1a1a; line-height:1.6;'; diff --git a/templates/index.html b/templates/index.html index 4545891..f2f1234 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3393,7 +3393,7 @@ - + diff --git a/tests/test_ook.py b/tests/test_ook.py index 4026c5b..a17fdad 100644 --- a/tests/test_ook.py +++ b/tests/test_ook.py @@ -6,12 +6,12 @@ import io import json import queue import threading +import unittest.mock import pytest from utils.ook import decode_ook_frame, ook_parser_thread - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -250,3 +250,112 @@ class TestOokRoutes: json={'frequency': '-5'}, content_type='application/json') assert resp.status_code == 400 + + def test_start_rejects_out_of_range_timing(self, client): + """Timing params that exceed server-side max should be rejected.""" + _login_session(client) + resp = client.post('/ook/start', + json={'short_pulse': 999999}, + content_type='application/json') + assert resp.status_code == 400 + + def test_start_rejects_negative_timing(self, client): + _login_session(client) + resp = client.post('/ook/start', + json={'min_bits': -1}, + content_type='application/json') + assert resp.status_code == 400 + + def test_start_success_mocked(self, client, monkeypatch): + """start_ook with mocked Popen should return 'started'.""" + import subprocess + + import app as app_module + + _login_session(client) + + mock_proc = unittest.mock.MagicMock() + mock_proc.poll.return_value = None + mock_proc.stdout = io.BytesIO(b'') + mock_proc.stderr = io.BytesIO(b'') + mock_proc.pid = 12345 + + monkeypatch.setattr(subprocess, 'Popen', lambda *a, **kw: mock_proc) + monkeypatch.setattr(app_module, 'claim_sdr_device', lambda *a, **kw: None) + monkeypatch.setattr(app_module, 'release_sdr_device', lambda *a, **kw: None) + + resp = client.post('/ook/start', + json={'frequency': '433.920'}, + content_type='application/json') + data = resp.get_json() + assert resp.status_code == 200 + assert data['status'] == 'started' + assert 'command' in data + + # Cleanup + with app_module.ook_lock: + app_module.ook_process = None + + def test_stop_with_running_process(self, client, monkeypatch): + """stop_ook should clean up a running process.""" + import app as app_module + + _login_session(client) + + mock_proc = unittest.mock.MagicMock() + mock_proc.poll.return_value = None + mock_proc.stdout = None + mock_proc.stderr = None + mock_proc.pid = 12345 + + # Inject a fake running process + import routes.ook as ook_module + app_module.ook_process = mock_proc + ook_module._ook_stop_event = threading.Event() + ook_module._ook_parser_thread = None + ook_module.ook_active_device = 0 + ook_module.ook_active_sdr_type = 'rtlsdr' + + monkeypatch.setattr(app_module, 'release_sdr_device', lambda *a, **kw: None) + monkeypatch.setattr('utils.process.safe_terminate', lambda p: None) + monkeypatch.setattr('utils.process.unregister_process', lambda p: None) + + resp = client.post('/ook/stop') + data = resp.get_json() + assert resp.status_code == 200 + assert data['status'] == 'stopped' + assert app_module.ook_process is None + assert ook_module.ook_active_device is None + + def test_stream_endpoint(self, client): + """SSE stream endpoint should return text/event-stream.""" + _login_session(client) + resp = client.get('/ook/stream') + assert resp.content_type.startswith('text/event-stream') + assert resp.headers.get('Cache-Control') == 'no-cache' + + +# --------------------------------------------------------------------------- +# Parser thread — stopped status on exit +# --------------------------------------------------------------------------- + +class TestOokParserStoppedEvent: + def test_emits_stopped_on_normal_exit(self): + """Parser thread should emit a status: stopped event when stream ends.""" + stdout = io.BytesIO(b'') + output_queue = queue.Queue() + stop_event = threading.Event() + + t = threading.Thread( + target=ook_parser_thread, + args=(stdout, output_queue, stop_event), + ) + t.start() + t.join(timeout=2) + + events = [] + while not output_queue.empty(): + events.append(output_queue.get_nowait()) + status_events = [e for e in events if e.get('type') == 'status'] + assert len(status_events) == 1 + assert status_events[0]['text'] == 'stopped' diff --git a/utils/ook.py b/utils/ook.py index c4343ca..60e8217 100644 --- a/utils/ook.py +++ b/utils/ook.py @@ -77,11 +77,8 @@ def ook_parser_thread( """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. + ``data``). Emits a ``status: stopped`` event when the parser exits + (normal EOF or unexpected crash) so the frontend can update its UI. Args: rtl_stdout: rtl_433 stdout pipe. @@ -144,7 +141,7 @@ def ook_parser_thread( break if not codes: - logger.debug( + logger.warning( f'[rtl_433/ook] no code field — keys: {list(data.keys())}' ) try: @@ -165,21 +162,7 @@ def ook_parser_thread( 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 @@ -199,7 +182,7 @@ def ook_parser_thread( 'bits': frame['bits'], 'byte_count': frame['byte_count'], 'bit_count': frame['bit_count'], - 'inverted': inverted, + 'inverted': False, 'encoding': encoding, 'timestamp': timestamp, } @@ -210,8 +193,15 @@ def ook_parser_thread( pass except Exception as e: - logger.debug(f'OOK parser thread error: {e}') + logger.warning(f'OOK parser thread error: {e}') try: output_queue.put_nowait({'type': 'error', 'text': str(e)}) except queue.Full: pass + + # Notify frontend that the parser has stopped (covers both normal exit + # and unexpected rtl_433 crashes so the UI doesn't stay in "Listening"). + try: + output_queue.put_nowait({'type': 'status', 'text': 'stopped'}) + except queue.Full: + pass From 91989a021641a6bbee64b28a10e2fb94c79307d0 Mon Sep 17 00:00:00 2001 From: thatsatechnique <28403172+thatsatechnique@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:21:14 -0800 Subject: [PATCH 09/10] =?UTF-8?q?fix(ook):=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20stale=20process,=20XSS=20presets,=20localStorage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect crashed rtl_433 process via poll() and clean up stale state instead of permanently blocking restarts with 409 - Replace innerHTML+onclick preset rendering with createElement/addEventListener to prevent XSS via crafted localStorage frequency values - Normalize preset frequencies to toFixed(3) on save and render - Add try/catch + shape validation to loadPresets() for corrupted localStorage Co-Authored-By: Claude Opus 4.6 --- routes/ook.py | 6 +++++- static/js/modes/ook.js | 40 +++++++++++++++++++++++++++++++--------- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/routes/ook.py b/routes/ook.py index ddefde8..b02769a 100644 --- a/routes/ook.py +++ b/routes/ook.py @@ -63,7 +63,11 @@ def start_ook() -> Response: with app_module.ook_lock: if app_module.ook_process: - return jsonify({'status': 'error', 'message': 'OOK decoder already running'}), 409 + # If the process exited/crashed, clean up stale state and allow restart + if app_module.ook_process.poll() is not None: + cleanup_ook(emit_status=False) + else: + return jsonify({'status': 'error', 'message': 'OOK decoder already running'}), 409 data = request.json or {} diff --git a/static/js/modes/ook.js b/static/js/modes/ook.js index 60100fd..4ad6f1b 100644 --- a/static/js/modes/ook.js +++ b/static/js/modes/ook.js @@ -414,7 +414,16 @@ var OokMode = (function () { function loadPresets() { var saved = localStorage.getItem('ookFreqPresets'); - return saved ? JSON.parse(saved) : DEFAULT_FREQ_PRESETS.slice(); + if (!saved) return DEFAULT_FREQ_PRESETS.slice(); + try { + var parsed = JSON.parse(saved); + if (Array.isArray(parsed) && parsed.every(function (v) { + return typeof v === 'string' && Number.isFinite(Number.parseFloat(v)); + })) { + return parsed; + } + } catch (_) {} + return DEFAULT_FREQ_PRESETS.slice(); } function savePresets(presets) { @@ -425,24 +434,37 @@ var OokMode = (function () { var container = document.getElementById('ookPresetButtons'); if (!container) return; var presets = loadPresets(); - container.innerHTML = presets.map(function (freq) { - return ''; - }).join(''); + container.textContent = ''; + presets.forEach(function (freq) { + var num = Number.parseFloat(freq); + if (!Number.isFinite(num)) return; + var normalized = num.toFixed(3); + var btn = document.createElement('button'); + btn.className = 'preset-btn'; + btn.title = 'Right-click to remove'; + btn.textContent = normalized; + btn.addEventListener('click', function () { OokMode.setFreq(normalized); }); + btn.addEventListener('contextmenu', function (e) { + e.preventDefault(); + OokMode.removePreset(normalized); + }); + container.appendChild(btn); + }); } function addPreset() { var input = document.getElementById('ookNewPresetFreq'); if (!input) return; var freq = input.value.trim(); - if (!freq || isNaN(parseFloat(freq))) { + var num = Number.parseFloat(freq); + if (!freq || !Number.isFinite(num)) { alert('Enter a valid frequency (MHz)'); return; } + var normalized = num.toFixed(3); var presets = loadPresets(); - if (presets.indexOf(freq) === -1) { - presets.push(freq); + if (presets.indexOf(normalized) === -1) { + presets.push(normalized); savePresets(presets); renderPresets(); } From 7d9a22023088868ac289b6306a6885d07da439e7 Mon Sep 17 00:00:00 2001 From: thatsatechnique <28403172+thatsatechnique@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:55:07 -0800 Subject: [PATCH 10/10] fix(ook): replace innerHTML with createElement/textContent in appendFrameEntry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses final upstream review — all backend-derived values (timestamp, bit_count, rssi, hex, ascii) now use DOM methods instead of innerHTML interpolation, closing the last XSS surface. Bumps cache-buster to ook2. Co-Authored-By: Claude Opus 4.6 --- static/js/modes/ook.js | 55 +++++++++++++++++++++++++++++------------- templates/index.html | 2 +- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/static/js/modes/ook.js b/static/js/modes/ook.js index 4ad6f1b..451682c 100644 --- a/static/js/modes/ook.js +++ b/static/js/modes/ook.js @@ -235,25 +235,46 @@ var OokMode = (function () { 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' - : ''; + // Build header line: timestamp [bitcount] rssi (inv) + var tsSpan = document.createElement('span'); + tsSpan.style.color = 'var(--text-dim)'; + tsSpan.textContent = msg.timestamp; - div.innerHTML = - '' + msg.timestamp + '' + - ' [' + msg.bit_count + 'b]' + - rssiStr + suffix + - '
' + - '' + - 'hex: ' + interp.hex + - '' + - '
' + - '' + - 'ascii: ' + _esc(interp.ascii) + - ''; + var bcSpan = document.createElement('span'); + bcSpan.style.color = '#888'; + bcSpan.textContent = ' [' + msg.bit_count + 'b]'; + + div.appendChild(tsSpan); + div.appendChild(bcSpan); + + if (msg.rssi !== undefined && msg.rssi !== null) { + var rssiSpan = document.createElement('span'); + rssiSpan.style.cssText = 'color:#666; font-size:10px'; + rssiSpan.textContent = ' ' + msg.rssi.toFixed(1) + ' dB SNR'; + div.appendChild(rssiSpan); + } + + if (msg.inverted) { + var invSpan = document.createElement('span'); + invSpan.style.opacity = '.5'; + invSpan.textContent = ' (inv)'; + div.appendChild(invSpan); + } + + // Hex line + div.appendChild(document.createElement('br')); + var hexSpan = document.createElement('span'); + hexSpan.style.cssText = 'padding-left:8em; color:' + color + '; font-family:var(--font-mono); font-size:10px'; + hexSpan.textContent = 'hex: ' + interp.hex; + div.appendChild(hexSpan); + + // ASCII line + div.appendChild(document.createElement('br')); + var ascSpan = document.createElement('span'); + ascSpan.style.cssText = 'padding-left:8em; color:' + (hasPrintable ? '#aaffcc' : '#555') + '; font-family:var(--font-mono); font-size:10px'; + ascSpan.textContent = 'ascii: ' + interp.ascii; + div.appendChild(ascSpan); div.style.cssText = 'font-size:11px; padding: 4px 0; border-bottom: 1px solid #1a1a1a; line-height:1.6;'; diff --git a/templates/index.html b/templates/index.html index f2f1234..09f317a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3393,7 +3393,7 @@ - +