mirror of
https://github.com/smittix/intercept.git
synced 2026-07-06 08:38:14 -07:00
96172ca593
First-time run of ruff-format via pre-commit hook normalises quote style, trailing commas, and whitespace across 188 Python files. No logic changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
200 lines
7.0 KiB
Python
200 lines
7.0 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 contextlib
|
|
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``). Emits a ``status: stopped`` event when the parser exits
|
|
(normal EOF or unexpected crash) so the frontend can update its UI.
|
|
|
|
Args:
|
|
rtl_stdout: rtl_433 stdout pipe.
|
|
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 and 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:
|
|
with contextlib.suppress(TypeError, ValueError):
|
|
rssi = round(float(_rssi_val), 1)
|
|
break
|
|
|
|
if not codes:
|
|
logger.warning(f"[rtl_433/ook] no code field — keys: {list(data.keys())}")
|
|
with contextlib.suppress(queue.Full):
|
|
output_queue.put_nowait(
|
|
{
|
|
"type": "ook_raw",
|
|
"data": data,
|
|
"timestamp": datetime.now().strftime("%H:%M:%S"),
|
|
}
|
|
)
|
|
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 :]
|
|
|
|
frame = decode_ook_frame(hex_str)
|
|
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": False,
|
|
"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.warning(f"OOK parser thread error: {e}")
|
|
with contextlib.suppress(queue.Full):
|
|
output_queue.put_nowait({"type": "error", "text": str(e)})
|
|
|
|
# Notify frontend that the parser has stopped (covers both normal exit
|
|
# and unexpected rtl_433 crashes so the UI doesn't stay in "Listening").
|
|
with contextlib.suppress(queue.Full):
|
|
output_queue.put_nowait({"type": "status", "text": "stopped"})
|