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