mirror of
https://github.com/smittix/intercept.git
synced 2026-05-30 02:19:28 -07:00
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:
197
utils/ook.py
Normal file
197
utils/ook.py
Normal 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
|
||||
Reference in New Issue
Block a user