feat: add Generic OOK Signal Decoder module

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 <noreply@anthropic.com>
This commit is contained in:
thatsatechnique
2026-03-04 11:51:38 -08:00
parent 4741124d94
commit 4c282bb055
8 changed files with 1019 additions and 2 deletions

5
app.py
View File

@@ -213,6 +213,11 @@ meteor_process = None
meteor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) meteor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
meteor_lock = threading.Lock() 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 Attack Detection
deauth_detector = None deauth_detector = None
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)

View File

@@ -18,6 +18,7 @@ def register_blueprints(app):
from .meshtastic import meshtastic_bp from .meshtastic import meshtastic_bp
from .meteor_websocket import meteor_bp from .meteor_websocket import meteor_bp
from .morse import morse_bp from .morse import morse_bp
from .ook import ook_bp
from .offline import offline_bp from .offline import offline_bp
from .pager import pager_bp from .pager import pager_bp
from .radiosonde import radiosonde_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(morse_bp) # CW/Morse code decoder
app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking
app.register_blueprint(system_bp) # System health monitoring 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 # Initialize TSCM state with queue and lock from app
import app as app_module import app as app_module

281
routes/ook.py Normal file
View File

@@ -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

358
static/js/modes/ook.js Normal file
View File

@@ -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 += ' <span style="opacity:.5">(inv)</span>';
div.innerHTML =
'<span style="color:var(--text-dim)">' + msg.timestamp + '</span>' +
' <span style="color:#888">[' + msg.bit_count + 'b]</span>' +
suffix +
'<br>' +
'<span style="padding-left:8em; color:' + color + '; font-family:var(--font-mono); font-size:10px">' +
'hex: ' + interp.hex +
'</span>' +
'<br>' +
'<span style="padding-left:8em; color:' + (hasPrintable ? '#aaffcc' : '#555') + '; font-family:var(--font-mono); font-size:10px">' +
'ascii: ' + interp.ascii +
'</span>';
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,
};
})();

View File

@@ -287,6 +287,10 @@
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg></span> <span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg></span>
<span class="mode-name">Morse</span> <span class="mode-name">Morse</span>
</button> </button>
<button class="mode-card mode-card-sm" onclick="selectMode('ook')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h3"/><path d="M19 12h3"/><rect x="5" y="8" width="4" height="8" rx="1"/><rect x="10" y="9" width="4" height="6" rx="1"/><rect x="15" y="7" width="4" height="10" rx="1"/></svg></span>
<span class="mode-name">OOK Decoder</span>
</button>
</div> </div>
</div> </div>
@@ -697,6 +701,8 @@
{% include 'partials/modes/morse.html' %} {% include 'partials/modes/morse.html' %}
{% include 'partials/modes/ook.html' %}
{% include 'partials/modes/space-weather.html' %} {% include 'partials/modes/space-weather.html' %}
{% include 'partials/modes/tscm.html' %} {% include 'partials/modes/tscm.html' %}
@@ -3285,6 +3291,29 @@
</div> </div>
</div> </div>
<!-- OOK Decoder Output Panel -->
<div id="ookOutputPanel" style="display: none; margin-bottom: 12px;">
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
<span>Decoded Frames</span>
<div style="display: flex; gap: 6px; align-items: center;">
<span style="color: var(--text-dim); font-size: 10px;">Bit order:</span>
<button class="btn btn-sm btn-ghost" id="ookBitMSB"
onclick="OokMode.setBitOrder('msb')"
style="background: var(--accent); color: #000;">MSB</button>
<button class="btn btn-sm btn-ghost" id="ookBitLSB"
onclick="OokMode.setBitOrder('lsb')">LSB</button>
<button class="btn btn-sm btn-ghost" onclick="OokMode.clearOutput()">Clear</button>
<button class="btn btn-sm btn-ghost" onclick="OokMode.exportLog()">CSV</button>
</div>
</div>
<div id="ookOutput" style="max-height: 400px; overflow-y: auto; font-family: var(--font-mono); font-size: 10px; color: var(--text-dim);"></div>
</div>
<div style="margin-top: 4px; font-size: 10px; color: #555; text-align: right;">
<span id="ookStatusBarFrames">0 frames</span>
</div>
</div>
<div class="output-content signal-feed" id="output"> <div class="output-content signal-feed" id="output">
<div class="placeholder signal-empty-state"> <div class="placeholder signal-empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -3356,6 +3385,7 @@
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script> <script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script>
<script src="{{ url_for('static', filename='js/modes/wefax.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/wefax.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/morse.js') }}?v={{ version }}&r=morse_iq12"></script> <script src="{{ url_for('static', filename='js/modes/morse.js') }}?v={{ version }}&r=morse_iq12"></script>
<script src="{{ url_for('static', filename='js/modes/ook.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/system.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/system.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/meteor.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/meteor.js') }}"></script>
@@ -3515,6 +3545,7 @@
waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' }, waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' },
morse: { label: 'Morse', indicator: 'MORSE', outputTitle: 'CW/Morse Decoder', group: 'signals' }, morse: { label: 'Morse', indicator: 'MORSE', outputTitle: 'CW/Morse Decoder', group: 'signals' },
system: { label: 'System', indicator: 'SYSTEM', outputTitle: 'System Health Monitor', group: 'system' }, 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)); const validModes = new Set(Object.keys(modeCatalog));
window.interceptModeCatalog = Object.assign({}, modeCatalog); window.interceptModeCatalog = Object.assign({}, modeCatalog);
@@ -4363,6 +4394,7 @@
document.getElementById('morseMode')?.classList.toggle('active', mode === 'morse'); document.getElementById('morseMode')?.classList.toggle('active', mode === 'morse');
document.getElementById('meteorMode')?.classList.toggle('active', mode === 'meteor'); document.getElementById('meteorMode')?.classList.toggle('active', mode === 'meteor');
document.getElementById('systemMode')?.classList.toggle('active', mode === 'system'); document.getElementById('systemMode')?.classList.toggle('active', mode === 'system');
document.getElementById('ookMode')?.classList.toggle('active', mode === 'ook');
const pagerStats = document.getElementById('pagerStats'); const pagerStats = document.getElementById('pagerStats');
@@ -4462,6 +4494,8 @@
if (morseOutputPanel && mode !== 'morse') morseOutputPanel.style.display = 'none'; if (morseOutputPanel && mode !== 'morse') morseOutputPanel.style.display = 'none';
const morseDiagLog = document.getElementById('morseDiagLog'); const morseDiagLog = document.getElementById('morseDiagLog');
if (morseDiagLog && mode !== 'morse') morseDiagLog.style.display = 'none'; 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 // Update output panel title based on mode
const outputTitle = document.getElementById('outputTitle'); const outputTitle = document.getElementById('outputTitle');
@@ -4503,16 +4537,17 @@
// Show RTL-SDR device section for modes that use it // Show RTL-SDR device section for modes that use it
const rtlDeviceSection = document.getElementById('rtlDeviceSection'); const rtlDeviceSection = document.getElementById('rtlDeviceSection');
if (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) // Save original sidebar position of SDR device section (once)
if (!rtlDeviceSection._origParent) { if (!rtlDeviceSection._origParent) {
rtlDeviceSection._origParent = rtlDeviceSection.parentNode; rtlDeviceSection._origParent = rtlDeviceSection.parentNode;
rtlDeviceSection._origNext = rtlDeviceSection.nextElementSibling; 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 morsePanel = document.getElementById('morseMode');
const radiosondePanel = document.getElementById('radiosondeMode'); const radiosondePanel = document.getElementById('radiosondeMode');
const meteorPanel = document.getElementById('meteorMode'); const meteorPanel = document.getElementById('meteorMode');
const ookPanel = document.getElementById('ookMode');
if (mode === 'morse' && morsePanel) { if (mode === 'morse' && morsePanel) {
const firstSection = morsePanel.querySelector('.section'); const firstSection = morsePanel.querySelector('.section');
if (firstSection) firstSection.after(rtlDeviceSection); if (firstSection) firstSection.after(rtlDeviceSection);
@@ -4522,6 +4557,9 @@
} else if (mode === 'meteor' && meteorPanel) { } else if (mode === 'meteor' && meteorPanel) {
const firstSection = meteorPanel.querySelector('.section'); const firstSection = meteorPanel.querySelector('.section');
if (firstSection) firstSection.after(rtlDeviceSection); 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) { } else if (rtlDeviceSection._origParent && rtlDeviceSection.parentNode !== rtlDeviceSection._origParent) {
// Restore to original sidebar position when leaving morse mode // Restore to original sidebar position when leaving morse mode
if (rtlDeviceSection._origNext) { if (rtlDeviceSection._origNext) {
@@ -4626,6 +4664,8 @@
MeteorScatter.init(); MeteorScatter.init();
} else if (mode === 'system') { } else if (mode === 'system') {
SystemHealth.init(); SystemHealth.init();
} else if (mode === 'ook') {
OokMode.init();
} }
// Waterfall destroy is now handled by moduleDestroyMap above. // Waterfall destroy is now handled by moduleDestroyMap above.

View File

@@ -0,0 +1,132 @@
<!-- OOK SIGNAL DECODER MODE -->
<div id="ookMode" class="mode-content">
<div class="section">
<h3>OOK Signal Decoder</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
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.
</p>
</div>
<div class="section">
<h3>Frequency</h3>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="ookFrequency" value="433.920" step="0.001" min="1" max="1766">
</div>
<div class="form-group">
<label>Presets</label>
<div style="display: flex; flex-wrap: wrap; gap: 4px;">
<button class="preset-btn" onclick="OokMode.setFreq(433.200)">433.2</button>
<button class="preset-btn" onclick="OokMode.setFreq(433.500)">433.5</button>
<button class="preset-btn" onclick="OokMode.setFreq(433.600)">433.6</button>
<button class="preset-btn" onclick="OokMode.setFreq(433.700)">433.7</button>
<button class="preset-btn" onclick="OokMode.setFreq(433.800)">433.8</button>
<button class="preset-btn" onclick="OokMode.setFreq(433.920)">433.9</button>
<button class="preset-btn" onclick="OokMode.setFreq(315.000)">315</button>
<button class="preset-btn" onclick="OokMode.setFreq(868.000)">868</button>
<button class="preset-btn" onclick="OokMode.setFreq(915.000)">915</button>
</div>
</div>
</div>
<div class="section">
<h3>SDR Settings</h3>
<div class="form-group">
<label>Gain (dB)</label>
<input type="number" id="ookGain" value="40" step="1" min="0" max="50">
</div>
<div class="form-group">
<label>PPM Correction</label>
<input type="number" id="ookPPM" value="0" step="1" min="-100" max="100">
</div>
</div>
<div class="section">
<h3>Modulation</h3>
<div class="form-group">
<div style="display: flex; gap: 4px;">
<button class="preset-btn" id="ookEnc_pwm"
onclick="OokMode.setEncoding('pwm')"
style="flex: 1; background: var(--accent); color: #000;">PWM</button>
<button class="preset-btn" id="ookEnc_ppm"
onclick="OokMode.setEncoding('ppm')"
style="flex: 1;">PPM</button>
<button class="preset-btn" id="ookEnc_manchester"
onclick="OokMode.setEncoding('manchester')"
style="flex: 1;">Manchester</button>
</div>
<input type="hidden" id="ookEncoding" value="pwm">
<p id="ookEncodingHint" class="info-text" style="font-size: 10px; color: var(--text-dim); margin-top: 4px;">
Short pulse = 0, long pulse = 1. Most common for ISM OOK.
</p>
</div>
</div>
<div class="section">
<h3>Pulse Timing</h3>
<p class="info-text" style="font-size: 10px; color: var(--text-dim); margin-bottom: 8px;">
Pulse widths in microseconds for the flex decoder.
</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
<div class="form-group">
<label>Short (&mu;s)</label>
<input type="number" id="ookShortPulse" value="300" step="10" min="50" max="5000">
</div>
<div class="form-group">
<label>Long (&mu;s)</label>
<input type="number" id="ookLongPulse" value="600" step="10" min="100" max="10000">
</div>
<div class="form-group">
<label>Gap/Reset (&mu;s)</label>
<input type="number" id="ookResetLimit" value="8000" step="100" min="500" max="50000">
</div>
<div class="form-group">
<label>Gap limit (&mu;s)</label>
<input type="number" id="ookGapLimit" value="5000" step="100" min="500" max="50000">
</div>
<div class="form-group">
<label>Tolerance (&mu;s)</label>
<input type="number" id="ookTolerance" value="150" step="10" min="10" max="1000">
</div>
<div class="form-group">
<label>Min bits</label>
<input type="number" id="ookMinBits" value="8" step="1" min="1" max="512">
</div>
</div>
</div>
<div class="section">
<h3>Options</h3>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="ookDeduplicate" style="width: auto; margin: 0;">
<span>Deduplicate frames</span>
</label>
<p class="info-text" style="font-size: 10px; color: var(--text-dim); margin-top: 4px;">
Suppress consecutive frames with identical hex content.
Useful when a transmitter repeats the same packet multiple times.
</p>
</div>
</div>
<!-- Status -->
<div class="section">
<div style="display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim);">
<span id="ookStatusIndicator" class="status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim);"></span>
<span id="ookStatusText">Standby</span>
<span style="margin-left: auto;" id="ookFrameCount">0 frames</span>
</div>
</div>
<div class="section">
<p class="info-text" style="font-size: 11px; color: #ffaa00; line-height: 1.5;">
Uses rtl_433 with a custom flex decoder. Requires rtl_433 installed.
Works on any OOK/ASK signal in the SDR's frequency range.
</p>
</div>
<button class="run-btn" id="ookStartBtn" onclick="OokMode.start()">Start Decoder</button>
<button class="stop-btn" id="ookStopBtn" onclick="OokMode.stop()" style="display: none;">Stop Decoder</button>
</div>

View File

@@ -68,6 +68,7 @@
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }} {{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
{{ mode_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.4"/><path d="M2 21h20" opacity="0.2"/></svg>') }} {{ mode_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.4"/><path d="M2 21h20" opacity="0.2"/></svg>') }}
{{ mode_item('morse', 'Morse', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg>') }} {{ mode_item('morse', 'Morse', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg>') }}
{{ mode_item('ook', 'OOK Decoder', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h3"/><path d="M19 12h3"/><rect x="5" y="8" width="4" height="8" rx="1"/><rect x="10" y="9" width="4" height="6" rx="1"/><rect x="15" y="7" width="4" height="10" rx="1"/></svg>') }}
</div> </div>
</div> </div>
@@ -218,6 +219,7 @@
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }} {{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }} {{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
{{ mobile_item('morse', 'Morse', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg>') }} {{ mobile_item('morse', 'Morse', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg>') }}
{{ mobile_item('ook', 'OOK', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h3"/><path d="M19 12h3"/><rect x="5" y="8" width="4" height="8" rx="1"/><rect x="10" y="9" width="4" height="6" rx="1"/><rect x="15" y="7" width="4" height="10" rx="1"/></svg>') }}
{# Tracking #} {# Tracking #}
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }} {{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
{{ mobile_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }} {{ mobile_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}

197
utils/ook.py Normal file
View File

@@ -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