Files
intercept/utils/ook.py
thatsatechnique 93fb694e25 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 <noreply@anthropic.com>
2026-03-04 14:29:55 -08:00

218 lines
7.5 KiB
Python

"""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:
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
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, 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.
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)]
# 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())}'
)
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:
event: dict[str, Any] = {
'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,
}
if rssi is not None:
event['rssi'] = rssi
output_queue.put_nowait(event)
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