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 1/6] 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 @@
+
+
+
+
+ Decoded Frames
+
+ Bit order:
+
+
+
+
+
+
+
+
+
+ 0 frames
+
+
+
@@ -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 2/6] 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 @@
+
Decoded Frames
@@ -3303,10 +3304,21 @@
style="background: var(--accent); color: #000;">MSB
+
+
+
+
+
@@ -13091,7 +13103,7 @@
`;
- // Add "Listen" button for RF signals
+ // Add "Listen" and "Decode (OOK)" buttons for RF signals
if (protocol === 'rf' && device.frequency) {
const freq = device.frequency;
html += `
@@ -13101,6 +13113,10 @@
+
`;
}
@@ -13114,7 +13130,7 @@
- ${protocol === 'rf' ? 'Listen buttons open Spectrum Waterfall. ' : ''}Known devices are excluded from threat scoring in future sweeps.
+ ${protocol === 'rf' ? 'Listen opens Spectrum Waterfall. Decode (OOK) opens the OOK decoder tuned to this frequency. ' : ''}Known devices are excluded from threat scoring in future sweeps.
`;
@@ -13910,6 +13926,17 @@
}, 300);
}
+ function decodeWithOok(frequency) {
+ // Close the TSCM modal and switch to OOK decoder with the detected frequency pre-filled
+ closeTscmDeviceModal();
+ switchMode('ook');
+ setTimeout(function () {
+ if (typeof OokMode !== 'undefined' && typeof OokMode.setFreq === 'function') {
+ OokMode.setFreq(parseFloat(frequency).toFixed(3));
+ }
+ }, 300);
+ }
+
async function showDevicesByCategory(category) {
const modal = document.getElementById('tscmDeviceModal');
const content = document.getElementById('tscmDeviceModalContent');
diff --git a/templates/partials/modes/ook.html b/templates/partials/modes/ook.html
index 98c86a9..ab89738 100644
--- a/templates/partials/modes/ook.html
+++ b/templates/partials/modes/ook.html
@@ -95,6 +95,21 @@
+
+
+
+
+
+
+
+
+
+
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 3/6] 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 @@
Messages cleared. ${isRunning || isSensorRunning ? 'Waiting for new messages...' : 'Start decoding to receive messages.'}
diff --git a/templates/partials/modes/ook.html b/templates/partials/modes/ook.html
index ab89738..f99169b 100644
--- a/templates/partials/modes/ook.html
+++ b/templates/partials/modes/ook.html
@@ -59,7 +59,7 @@
- Short pulse = 0, long pulse = 1. Most common for ISM OOK.
+ Short pulse = 1, long pulse = 0. Most common for ISM OOK.
@@ -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.
+
+
+ Active Command
+
+
+
+
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 4/6] 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 modulation — PWM: 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 5/6] 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 6/6] 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 modulation — PWM: 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 modulation — PWM: 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.