Files
intercept/routes/ook.py
T
James Smith 96172ca593 style: apply ruff-format to entire codebase
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>
2026-07-05 14:48:11 +01:00

353 lines
12 KiB
Python

"""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 os
import queue
import signal
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.responses import api_error
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_positive_int,
validate_ppm,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
ook_bp = Blueprint("ook", __name__)
# Track which device / SDR type is being used
ook_active_device: int | None = None
ook_active_sdr_type: str | None = None
# Parser thread state (avoids monkey-patching subprocess.Popen)
_ook_stop_event: threading.Event | None = None
_ook_parser_thread: threading.Thread | 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, ook_active_sdr_type, _ook_stop_event, _ook_parser_thread
with app_module.ook_lock:
if app_module.ook_process:
# If the process exited/crashed, clean up stale state and allow restart
if app_module.ook_process.poll() is not None:
cleanup_ook(emit_status=False)
else:
return api_error("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 api_error(str(e), 400)
try:
encoding = _validate_encoding(data.get("encoding", "pwm"))
except ValueError as e:
return api_error(str(e), 400)
# OOK flex decoder timing parameters (server-side range validation)
try:
short_pulse = validate_positive_int(data.get("short_pulse", 300), "short_pulse", max_val=100000)
long_pulse = validate_positive_int(data.get("long_pulse", 600), "long_pulse", max_val=100000)
reset_limit = validate_positive_int(data.get("reset_limit", 8000), "reset_limit", max_val=1000000)
gap_limit = validate_positive_int(data.get("gap_limit", 5000), "gap_limit", max_val=1000000)
tolerance = validate_positive_int(data.get("tolerance", 150), "tolerance", max_val=50000)
min_bits = validate_positive_int(data.get("min_bits", 8), "min_bits", max_val=4096)
except ValueError as e:
return api_error(f"Invalid timing parameter: {e}", 400)
if min_bits < 1:
return api_error("min_bits must be >= 1", 400)
if short_pulse < 1 or long_pulse < 1:
return api_error("Pulse widths must be >= 1", 400)
deduplicate = bool(data.get("deduplicate", False))
# Parse SDR type early — needed for device claim
sdr_type_str = data.get("sdr_type", "rtlsdr")
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
sdr_type_str = "rtlsdr"
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", sdr_type_str)
if error:
return api_error(error, 409, error_type="DEVICE_BUSY")
ook_active_device = device_int
ook_active_sdr_type = sdr_type_str
while not app_module.ook_queue.empty():
try:
app_module.ook_queue.get_nowait()
except queue.Empty:
break
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 api_error(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(["-M", "level", "-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,
start_new_session=True,
)
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
_ook_stop_event = stop_event
_ook_parser_thread = parser_thread
try:
app_module.ook_queue.put_nowait({"type": "status", "text": "started"})
except queue.Full:
logger.warning("OOK 'started' status dropped — queue full")
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_sdr_type or "rtlsdr")
ook_active_device = None
ook_active_sdr_type = None
return api_error(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_sdr_type or "rtlsdr")
ook_active_device = None
ook_active_sdr_type = None
return api_error(str(e), 500)
def _close_pipe(pipe_obj) -> None:
"""Close a subprocess pipe, suppressing errors."""
if pipe_obj is not None:
with contextlib.suppress(Exception):
pipe_obj.close()
def cleanup_ook(*, emit_status: bool = True) -> None:
"""Full OOK cleanup: stop parser, terminate process, release SDR device.
Safe to call from ``stop_ook()`` and ``kill_all()``. Caller must hold
``app_module.ook_lock``.
"""
global ook_active_device, ook_active_sdr_type, _ook_stop_event, _ook_parser_thread
proc = app_module.ook_process
if not proc:
return
# Signal parser thread to stop
if _ook_stop_event:
_ook_stop_event.set()
# Close pipes so parser thread unblocks from readline()
_close_pipe(getattr(proc, "stdout", None))
_close_pipe(getattr(proc, "stderr", None))
# Kill the entire process group so child processes are cleaned up
try:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGTERM)
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
os.killpg(pgid, signal.SIGKILL)
proc.wait(timeout=3)
except (ProcessLookupError, OSError):
# Process already dead — fall back to normal terminate
safe_terminate(proc)
unregister_process(proc)
app_module.ook_process = None
# Join parser thread with timeout
if _ook_parser_thread:
_ook_parser_thread.join(timeout=0.5)
_ook_stop_event = None
_ook_parser_thread = None
if ook_active_device is not None:
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or "rtlsdr")
ook_active_device = None
ook_active_sdr_type = None
if emit_status:
try:
app_module.ook_queue.put_nowait({"type": "status", "text": "stopped"})
except queue.Full:
logger.warning("OOK 'stopped' status dropped — queue full")
@ook_bp.route("/ook/stop", methods=["POST"])
def stop_ook() -> Response:
with app_module.ook_lock:
if app_module.ook_process:
cleanup_ook()
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