mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Merge pull request #7 from thatsatechnique/feature/ook-decoder
Feature/ook decoder
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -64,3 +64,6 @@ data/subghz/captures/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Local utility scripts
|
||||
reset-sdr.*
|
||||
|
||||
5
app.py
5
app.py
@@ -213,6 +213,11 @@ meteor_process = None
|
||||
meteor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
meteor_lock = threading.Lock()
|
||||
|
||||
# Generic OOK signal decoder
|
||||
ook_process = None
|
||||
ook_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
ook_lock = threading.Lock()
|
||||
|
||||
# Deauth Attack Detection
|
||||
deauth_detector = None
|
||||
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
|
||||
@@ -18,6 +18,7 @@ def register_blueprints(app):
|
||||
from .meshtastic import meshtastic_bp
|
||||
from .meteor_websocket import meteor_bp
|
||||
from .morse import morse_bp
|
||||
from .ook import ook_bp
|
||||
from .offline import offline_bp
|
||||
from .pager import pager_bp
|
||||
from .radiosonde import radiosonde_bp
|
||||
@@ -81,6 +82,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(morse_bp) # CW/Morse code decoder
|
||||
app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking
|
||||
app.register_blueprint(system_bp) # System health monitoring
|
||||
app.register_blueprint(ook_bp) # Generic OOK signal decoder
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
|
||||
290
routes/ook.py
Normal file
290
routes/ook.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""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 queue
|
||||
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.sdr import SDRFactory, SDRType
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.validation import (
|
||||
validate_device_index,
|
||||
validate_frequency,
|
||||
validate_gain,
|
||||
validate_ppm,
|
||||
validate_rtl_tcp_host,
|
||||
validate_rtl_tcp_port,
|
||||
)
|
||||
|
||||
ook_bp = Blueprint('ook', __name__)
|
||||
|
||||
# Track which device is being used
|
||||
ook_active_device: int | 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
|
||||
|
||||
with app_module.ook_lock:
|
||||
if app_module.ook_process:
|
||||
return jsonify({'status': 'error', 'message': '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 jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
try:
|
||||
encoding = _validate_encoding(data.get('encoding', 'pwm'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# OOK flex decoder timing parameters
|
||||
try:
|
||||
short_pulse = int(data.get('short_pulse', 300))
|
||||
long_pulse = int(data.get('long_pulse', 600))
|
||||
reset_limit = int(data.get('reset_limit', 8000))
|
||||
gap_limit = int(data.get('gap_limit', 5000))
|
||||
tolerance = int(data.get('tolerance', 150))
|
||||
min_bits = int(data.get('min_bits', 8))
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid timing parameter: {e}'}), 400
|
||||
deduplicate = bool(data.get('deduplicate', False))
|
||||
|
||||
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')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
ook_active_device = device_int
|
||||
|
||||
while not app_module.ook_queue.empty():
|
||||
try:
|
||||
app_module.ook_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
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 jsonify({'status': 'error', 'message': 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,
|
||||
)
|
||||
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
|
||||
app_module.ook_process._stop_parser = stop_event
|
||||
app_module.ook_process._parser_thread = parser_thread
|
||||
|
||||
try:
|
||||
app_module.ook_queue.put_nowait({'type': 'status', 'status': '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_device = None
|
||||
return jsonify({'status': 'error', 'message': 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_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@ook_bp.route('/ook/stop', methods=['POST'])
|
||||
def stop_ook() -> Response:
|
||||
global ook_active_device
|
||||
|
||||
with app_module.ook_lock:
|
||||
if app_module.ook_process:
|
||||
stop_event = getattr(app_module.ook_process, '_stop_parser', None)
|
||||
if stop_event:
|
||||
stop_event.set()
|
||||
|
||||
safe_terminate(app_module.ook_process)
|
||||
unregister_process(app_module.ook_process)
|
||||
app_module.ook_process = None
|
||||
|
||||
if ook_active_device is not None:
|
||||
app_module.release_sdr_device(ook_active_device)
|
||||
ook_active_device = None
|
||||
|
||||
try:
|
||||
app_module.ook_queue.put_nowait({'type': 'status', 'status': 'stopped'})
|
||||
except queue.Full:
|
||||
logger.warning("OOK 'stopped' status dropped — queue full")
|
||||
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
|
||||
@@ -27,6 +27,21 @@ const CheatSheets = (function () {
|
||||
radiosonde: { title: 'Radiosonde Tracker', icon: '🎈', hardware: 'RTL-SDR dongle', description: 'Tracks weather balloons via radiosonde telemetry using radiosonde_auto_rx.', whatToExpect: 'Position, altitude, temperature, humidity, pressure from active sondes.', tips: ['Sondes transmit on 400–406 MHz', 'Set your region to narrow the scan range', 'Gain 40 dB is a good starting point'] },
|
||||
morse: { title: 'CW/Morse Decoder', icon: '📡', hardware: 'RTL-SDR + HF antenna (or upconverter)', description: 'Decodes CW Morse code via Goertzel tone detection or OOK envelope detection.', whatToExpect: 'Decoded Morse characters, WPM estimate, signal level.', tips: ['CW Tone mode for HF amateur bands (e.g. 7.030, 14.060 MHz)', 'OOK Envelope mode for ISM/UHF signals', 'Use band presets for quick tuning to CW sub-bands'] },
|
||||
meteor: { title: 'Meteor Scatter', icon: '☄️', hardware: 'RTL-SDR + VHF antenna (143 MHz)', description: 'Monitors VHF beacon reflections from meteor ionization trails.', whatToExpect: 'Waterfall display with transient ping detections and event logging.', tips: ['GRAVES radar at 143.050 MHz is the primary target', 'Use a Yagi pointed south (from Europe) for best results', 'Peak activity during annual meteor showers (Perseids, Geminids)'] },
|
||||
ook: {
|
||||
title: 'OOK Signal Decoder',
|
||||
icon: '📡',
|
||||
hardware: 'RTL-SDR dongle',
|
||||
description: 'Decodes raw On-Off Keying (OOK) signals via rtl_433 flex decoder. Captures frames with configurable pulse timing and displays raw bits, hex, and ASCII — useful for reverse-engineering unknown ISM-band protocols.',
|
||||
whatToExpect: 'Decoded bit sequences, hex payloads, and ASCII interpretation. Each frame shows bit count, timestamp, and optional RSSI.',
|
||||
tips: [
|
||||
'<strong>Identifying modulation</strong> — <em>PWM</em>: pulse widths vary (short=0, long=1), gaps constant — most common for ISM remotes/sensors. <em>PPM</em>: pulses constant, gap widths encode data. <em>Manchester</em>: self-clocking, equal-width pulses, data in transitions.',
|
||||
'<strong>Finding pulse timing</strong> — Run <code>rtl_433 -f 433.92M -A</code> in a terminal to auto-analyze signals. It prints detected pulse widths (short/long) and gap timings. Use those values in the Short/Long Pulse fields.',
|
||||
'<strong>Common ISM timings</strong> — 300/600µs (weather stations, door sensors), 400/800µs (car keyfobs), 500/1500µs (garage doors, doorbells), 500µs Manchester (tire pressure monitors).',
|
||||
'<strong>Frequencies to try</strong> — 315 MHz (North America keyfobs), 433.920 MHz (global ISM), 868 MHz (Europe ISM), 915 MHz (US ISM/meters).',
|
||||
'<strong>Troubleshooting</strong> — Garbled output? Try halving or doubling pulse timings. No frames? Increase tolerance (±200–300µs). Too many frames? Enable deduplication. Wrong characters? Toggle MSB/LSB bit order.',
|
||||
'<strong>Tolerance & reset</strong> — Tolerance is how much timing can drift (±150µs default). Reset limit is the silence gap that ends a frame (8000µs). Lower gap limit if frames are merging together.',
|
||||
]
|
||||
},
|
||||
};
|
||||
|
||||
function show(mode) {
|
||||
|
||||
575
static/js/modes/ook.js
Normal file
575
static/js/modes/ook.js
Normal file
@@ -0,0 +1,575 @@
|
||||
/**
|
||||
* Generic OOK Signal Decoder module.
|
||||
*
|
||||
* IIFE providing start/stop controls, SSE streaming, and a live-updating
|
||||
* frame log with configurable bit order (MSB/LSB) and ASCII interpretation.
|
||||
* The backend sends raw bits; all byte grouping and ASCII display is done
|
||||
* here so bit order can be flipped without restarting the decoder.
|
||||
*/
|
||||
var OokMode = (function () {
|
||||
'use strict';
|
||||
|
||||
var DEFAULT_FREQ_PRESETS = ['433.920', '315.000', '868.000', '915.000'];
|
||||
var MAX_FRAMES = 5000;
|
||||
|
||||
var state = {
|
||||
running: false,
|
||||
initialized: false,
|
||||
eventSource: null,
|
||||
frames: [], // raw frame objects from SSE
|
||||
frameCount: 0,
|
||||
bitOrder: 'msb', // 'msb' | 'lsb'
|
||||
filterQuery: '', // active hex/ascii filter
|
||||
command: '', // the rtl_433 command being run
|
||||
};
|
||||
|
||||
// ---- Initialization ----
|
||||
|
||||
function init() {
|
||||
if (state.initialized) {
|
||||
checkStatus();
|
||||
return;
|
||||
}
|
||||
state.initialized = true;
|
||||
renderPresets();
|
||||
checkStatus();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
disconnectSSE();
|
||||
}
|
||||
|
||||
// ---- Status ----
|
||||
|
||||
function checkStatus() {
|
||||
fetch('/ook/status')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.running) {
|
||||
state.running = true;
|
||||
updateUI(true);
|
||||
connectSSE();
|
||||
} else {
|
||||
state.running = false;
|
||||
updateUI(false);
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
// ---- Start / Stop ----
|
||||
|
||||
function start() {
|
||||
if (state.running) return;
|
||||
|
||||
var remoteSDR = typeof getRemoteSDRConfig === 'function' ? getRemoteSDRConfig() : null;
|
||||
if (remoteSDR === false) return;
|
||||
|
||||
var payload = {
|
||||
frequency: document.getElementById('ookFrequency').value || '433.920',
|
||||
gain: document.getElementById('ookGain').value || '0',
|
||||
ppm: document.getElementById('ookPPM').value || '0',
|
||||
device: document.getElementById('deviceSelect')?.value || '0',
|
||||
sdr_type: document.getElementById('sdrTypeSelect')?.value || 'rtlsdr',
|
||||
encoding: document.getElementById('ookEncoding').value || 'pwm',
|
||||
short_pulse: document.getElementById('ookShortPulse').value || '300',
|
||||
long_pulse: document.getElementById('ookLongPulse').value || '600',
|
||||
reset_limit: document.getElementById('ookResetLimit').value || '8000',
|
||||
gap_limit: document.getElementById('ookGapLimit').value || '5000',
|
||||
tolerance: document.getElementById('ookTolerance').value || '150',
|
||||
min_bits: document.getElementById('ookMinBits').value || '8',
|
||||
deduplicate: document.getElementById('ookDeduplicate')?.checked || false,
|
||||
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
|
||||
};
|
||||
if (remoteSDR) {
|
||||
payload.rtl_tcp_host = remoteSDR.host;
|
||||
payload.rtl_tcp_port = remoteSDR.port;
|
||||
}
|
||||
|
||||
fetch('/ook/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.status === 'started') {
|
||||
state.running = true;
|
||||
state.frames = [];
|
||||
state.frameCount = 0;
|
||||
updateUI(true);
|
||||
connectSSE();
|
||||
clearOutput();
|
||||
showCommand(data.command || '');
|
||||
} else {
|
||||
alert('Error: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
alert('Failed to start OOK decoder: ' + err);
|
||||
});
|
||||
}
|
||||
|
||||
function stop() {
|
||||
fetch('/ook/stop', { method: 'POST' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function () {
|
||||
state.running = false;
|
||||
updateUI(false);
|
||||
disconnectSSE();
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
// ---- SSE ----
|
||||
|
||||
function connectSSE() {
|
||||
disconnectSSE();
|
||||
var es = new EventSource('/ook/stream');
|
||||
es.onmessage = function (e) {
|
||||
try {
|
||||
var msg = JSON.parse(e.data);
|
||||
handleMessage(msg);
|
||||
} catch (_) {}
|
||||
};
|
||||
es.onerror = function () {};
|
||||
state.eventSource = es;
|
||||
}
|
||||
|
||||
function disconnectSSE() {
|
||||
if (state.eventSource) {
|
||||
state.eventSource.close();
|
||||
state.eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(msg) {
|
||||
if (msg.type === 'ook_frame') {
|
||||
handleFrame(msg);
|
||||
} else if (msg.type === 'status') {
|
||||
if (msg.status === 'stopped') {
|
||||
state.running = false;
|
||||
updateUI(false);
|
||||
disconnectSSE();
|
||||
}
|
||||
} else if (msg.type === 'error') {
|
||||
console.error('OOK error:', msg.text);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Frame handling ----
|
||||
|
||||
function handleFrame(msg) {
|
||||
state.frames.push(msg);
|
||||
state.frameCount++;
|
||||
|
||||
// Trim oldest frames when buffer exceeds cap
|
||||
if (state.frames.length > MAX_FRAMES) {
|
||||
state.frames.splice(0, state.frames.length - MAX_FRAMES);
|
||||
var panel = document.getElementById('ookOutput');
|
||||
if (panel && panel.firstChild) panel.removeChild(panel.firstChild);
|
||||
}
|
||||
|
||||
var countEl = document.getElementById('ookFrameCount');
|
||||
if (countEl) countEl.textContent = state.frameCount + ' frames';
|
||||
var barEl = document.getElementById('ookStatusBarFrames');
|
||||
if (barEl) barEl.textContent = state.frameCount + ' frames';
|
||||
|
||||
appendFrameEntry(msg, state.bitOrder);
|
||||
}
|
||||
|
||||
// ---- Bit interpretation ----
|
||||
|
||||
/**
|
||||
* Interpret a raw bit string as bytes and attempt ASCII.
|
||||
* @param {string} bits - MSB-first bit string from backend
|
||||
* @param {string} order - 'msb' | 'lsb'
|
||||
* @returns {{hex: string, ascii: string, printable: string}}
|
||||
*/
|
||||
function interpretBits(bits, order) {
|
||||
var hexChars = [];
|
||||
var asciiChars = [];
|
||||
var printableChars = [];
|
||||
|
||||
for (var i = 0; i + 8 <= bits.length; i += 8) {
|
||||
var byteBits = bits.slice(i, i + 8);
|
||||
if (order === 'lsb') {
|
||||
byteBits = byteBits.split('').reverse().join('');
|
||||
}
|
||||
var byteVal = parseInt(byteBits, 2);
|
||||
hexChars.push(byteVal.toString(16).padStart(2, '0'));
|
||||
|
||||
if (byteVal >= 0x20 && byteVal <= 0x7E) {
|
||||
asciiChars.push(String.fromCharCode(byteVal));
|
||||
printableChars.push(String.fromCharCode(byteVal));
|
||||
} else {
|
||||
asciiChars.push('.');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hex: hexChars.join(''),
|
||||
ascii: asciiChars.join(''),
|
||||
printable: printableChars.join(''),
|
||||
};
|
||||
}
|
||||
|
||||
function appendFrameEntry(msg, order) {
|
||||
var panel = document.getElementById('ookOutput');
|
||||
if (!panel) return;
|
||||
|
||||
var interp = interpretBits(msg.bits, order);
|
||||
var hasPrintable = interp.printable.length > 0;
|
||||
|
||||
var div = document.createElement('div');
|
||||
div.className = 'ook-frame';
|
||||
div.dataset.bits = msg.bits || '';
|
||||
div.dataset.bitCount = msg.bit_count;
|
||||
div.dataset.inverted = msg.inverted ? '1' : '0';
|
||||
|
||||
var color = hasPrintable ? '#00ff88' : 'var(--text-dim)';
|
||||
var suffix = '';
|
||||
if (msg.inverted) suffix += ' <span style="opacity:.5">(inv)</span>';
|
||||
|
||||
var rssiStr = (msg.rssi !== undefined && msg.rssi !== null)
|
||||
? ' <span style="color:#666; font-size:10px">' + msg.rssi.toFixed(1) + ' dB SNR</span>'
|
||||
: '';
|
||||
|
||||
div.innerHTML =
|
||||
'<span style="color:var(--text-dim)">' + msg.timestamp + '</span>' +
|
||||
' <span style="color:#888">[' + msg.bit_count + 'b]</span>' +
|
||||
rssiStr + suffix +
|
||||
'<br>' +
|
||||
'<span style="padding-left:8em; color:' + color + '; font-family:var(--font-mono); font-size:10px">' +
|
||||
'hex: ' + interp.hex +
|
||||
'</span>' +
|
||||
'<br>' +
|
||||
'<span style="padding-left:8em; color:' + (hasPrintable ? '#aaffcc' : '#555') + '; font-family:var(--font-mono); font-size:10px">' +
|
||||
'ascii: ' + (typeof escapeHtml === 'function' ? escapeHtml(interp.ascii) : interp.ascii) +
|
||||
'</span>';
|
||||
|
||||
div.style.cssText = 'font-size:11px; padding: 4px 0; border-bottom: 1px solid #1a1a1a; line-height:1.6;';
|
||||
|
||||
// Apply current filter
|
||||
if (state.filterQuery) {
|
||||
var q = state.filterQuery;
|
||||
if (!interp.hex.includes(q) && !interp.ascii.toLowerCase().includes(q)) {
|
||||
div.style.display = 'none';
|
||||
} else {
|
||||
div.style.background = 'rgba(0,255,136,0.05)';
|
||||
}
|
||||
}
|
||||
|
||||
panel.appendChild(div);
|
||||
if (typeof autoScroll === 'undefined' || autoScroll) {
|
||||
panel.scrollTop = panel.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Bit order toggle ----
|
||||
|
||||
function setBitOrder(order) {
|
||||
state.bitOrder = order;
|
||||
|
||||
// Update button states
|
||||
var msbBtn = document.getElementById('ookBitMSB');
|
||||
var lsbBtn = document.getElementById('ookBitLSB');
|
||||
if (msbBtn) msbBtn.style.background = order === 'msb' ? 'var(--accent)' : '';
|
||||
if (msbBtn) msbBtn.style.color = order === 'msb' ? '#000' : '';
|
||||
if (lsbBtn) lsbBtn.style.background = order === 'lsb' ? 'var(--accent)' : '';
|
||||
if (lsbBtn) lsbBtn.style.color = order === 'lsb' ? '#000' : '';
|
||||
|
||||
// Re-render all stored frames
|
||||
var panel = document.getElementById('ookOutput');
|
||||
if (!panel) return;
|
||||
panel.innerHTML = '';
|
||||
state.frames.forEach(function (msg) {
|
||||
appendFrameEntry(msg, order);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Output panel ----
|
||||
|
||||
function clearOutput() {
|
||||
var panel = document.getElementById('ookOutput');
|
||||
if (panel) panel.innerHTML = '';
|
||||
state.frames = [];
|
||||
state.frameCount = 0;
|
||||
var countEl = document.getElementById('ookFrameCount');
|
||||
if (countEl) countEl.textContent = '0 frames';
|
||||
var barEl = document.getElementById('ookStatusBarFrames');
|
||||
if (barEl) barEl.textContent = '0 frames';
|
||||
|
||||
// Hide output panel if not currently running (no frames to show)
|
||||
if (!state.running) {
|
||||
var outputPanel = document.getElementById('ookOutputPanel');
|
||||
if (outputPanel) outputPanel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function exportLog() {
|
||||
var lines = ['timestamp,bit_count,rssi_db,hex_msb,ascii_msb,inverted'];
|
||||
state.frames.forEach(function (msg) {
|
||||
var interp = interpretBits(msg.bits, 'msb');
|
||||
lines.push([
|
||||
msg.timestamp,
|
||||
msg.bit_count,
|
||||
msg.rssi !== undefined && msg.rssi !== null ? msg.rssi : '',
|
||||
interp.hex,
|
||||
'"' + interp.ascii.replace(/"/g, '""') + '"',
|
||||
msg.inverted,
|
||||
].join(','));
|
||||
});
|
||||
var blob = new Blob([lines.join('\n')], { type: 'text/csv' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'ook_frames.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function exportJSON() {
|
||||
if (state.frames.length === 0) { alert('No frames to export'); return; }
|
||||
var out = state.frames.map(function (msg) {
|
||||
var interp = interpretBits(msg.bits, state.bitOrder);
|
||||
return {
|
||||
timestamp: msg.timestamp,
|
||||
bit_count: msg.bit_count,
|
||||
rssi: (msg.rssi !== undefined && msg.rssi !== null) ? msg.rssi : null,
|
||||
hex: interp.hex,
|
||||
ascii: interp.ascii,
|
||||
inverted: msg.inverted,
|
||||
bits: msg.bits,
|
||||
};
|
||||
});
|
||||
var blob = new Blob([JSON.stringify(out, null, 2)], { type: 'application/json' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'ook_frames.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ---- Command display ----
|
||||
|
||||
function showCommand(cmd) {
|
||||
state.command = cmd;
|
||||
var display = document.getElementById('ookCommandDisplay');
|
||||
var text = document.getElementById('ookCommandText');
|
||||
if (display && text && cmd) {
|
||||
text.textContent = cmd;
|
||||
display.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function copyCommand() {
|
||||
if (state.command && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(state.command);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Modulation selector ----
|
||||
|
||||
function setEncoding(enc) {
|
||||
document.getElementById('ookEncoding').value = enc;
|
||||
|
||||
// Update button highlight
|
||||
['pwm', 'ppm', 'manchester'].forEach(function (e) {
|
||||
var btn = document.getElementById('ookEnc_' + e);
|
||||
if (!btn) return;
|
||||
if (e === enc) {
|
||||
btn.style.background = 'var(--accent)';
|
||||
btn.style.color = '#000';
|
||||
} else {
|
||||
btn.style.background = '';
|
||||
btn.style.color = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Update timing hint
|
||||
var hints = {
|
||||
pwm: 'Short pulse = 0, long pulse = 1. Most common for ISM OOK.',
|
||||
ppm: 'Short gap = 0, long gap = 1. Pulse position encoding.',
|
||||
manchester: 'Rising edge = 1, falling edge = 0. Self-clocking.',
|
||||
};
|
||||
var hint = document.getElementById('ookEncodingHint');
|
||||
if (hint) hint.textContent = hints[enc] || '';
|
||||
}
|
||||
|
||||
function setFreq(mhz) {
|
||||
var el = document.getElementById('ookFrequency');
|
||||
if (el) el.value = mhz;
|
||||
}
|
||||
|
||||
// ---- Frequency presets (localStorage) ----
|
||||
|
||||
function loadPresets() {
|
||||
var saved = localStorage.getItem('ookFreqPresets');
|
||||
return saved ? JSON.parse(saved) : DEFAULT_FREQ_PRESETS.slice();
|
||||
}
|
||||
|
||||
function savePresets(presets) {
|
||||
localStorage.setItem('ookFreqPresets', JSON.stringify(presets));
|
||||
}
|
||||
|
||||
function renderPresets() {
|
||||
var container = document.getElementById('ookPresetButtons');
|
||||
if (!container) return;
|
||||
var presets = loadPresets();
|
||||
container.innerHTML = presets.map(function (freq) {
|
||||
return '<button class="preset-btn" onclick="OokMode.setFreq(\'' + freq + '\')" ' +
|
||||
'oncontextmenu="OokMode.removePreset(\'' + freq + '\'); return false;" ' +
|
||||
'title="Right-click to remove">' + freq + '</button>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function addPreset() {
|
||||
var input = document.getElementById('ookNewPresetFreq');
|
||||
if (!input) return;
|
||||
var freq = input.value.trim();
|
||||
if (!freq || isNaN(parseFloat(freq))) {
|
||||
alert('Enter a valid frequency (MHz)');
|
||||
return;
|
||||
}
|
||||
var presets = loadPresets();
|
||||
if (presets.indexOf(freq) === -1) {
|
||||
presets.push(freq);
|
||||
savePresets(presets);
|
||||
renderPresets();
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function removePreset(freq) {
|
||||
if (!confirm('Remove preset ' + freq + ' MHz?')) return;
|
||||
var presets = loadPresets().filter(function (p) { return p !== freq; });
|
||||
savePresets(presets);
|
||||
renderPresets();
|
||||
}
|
||||
|
||||
function resetPresets() {
|
||||
if (!confirm('Reset to default presets?')) return;
|
||||
savePresets(DEFAULT_FREQ_PRESETS.slice());
|
||||
renderPresets();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a timing preset — fills all six pulse timing fields at once.
|
||||
* @param {number} s Short pulse (µs)
|
||||
* @param {number} l Long pulse (µs)
|
||||
* @param {number} r Reset/gap limit (µs)
|
||||
* @param {number} g Gap limit (µs)
|
||||
* @param {number} t Tolerance (µs)
|
||||
* @param {number} b Min bits
|
||||
*/
|
||||
function setTiming(s, l, r, g, t, b) {
|
||||
var fields = {
|
||||
ookShortPulse: s,
|
||||
ookLongPulse: l,
|
||||
ookResetLimit: r,
|
||||
ookGapLimit: g,
|
||||
ookTolerance: t,
|
||||
ookMinBits: b,
|
||||
};
|
||||
Object.keys(fields).forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.value = fields[id];
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Auto bit-order suggestion ----
|
||||
|
||||
/**
|
||||
* Count printable chars for MSB and LSB across all stored frames,
|
||||
* then switch to whichever produces more readable output.
|
||||
*/
|
||||
function suggestBitOrder() {
|
||||
if (state.frames.length === 0) return;
|
||||
var msbCount = 0, lsbCount = 0;
|
||||
state.frames.forEach(function (msg) {
|
||||
msbCount += interpretBits(msg.bits, 'msb').printable.length;
|
||||
lsbCount += interpretBits(msg.bits, 'lsb').printable.length;
|
||||
});
|
||||
var best = msbCount >= lsbCount ? 'msb' : 'lsb';
|
||||
setBitOrder(best);
|
||||
var label = document.getElementById('ookSuggestLabel');
|
||||
if (label) {
|
||||
var winner = best === 'msb' ? msbCount : lsbCount;
|
||||
label.textContent = best.toUpperCase() + ' (' + winner + ' printable)';
|
||||
label.style.color = '#00ff88';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Pattern search / filter ----
|
||||
|
||||
/**
|
||||
* Show only frames whose hex or ASCII interpretation contains the query.
|
||||
* Clears filter when query is empty.
|
||||
* @param {string} query
|
||||
*/
|
||||
function filterFrames(query) {
|
||||
state.filterQuery = query.toLowerCase().trim();
|
||||
var q = state.filterQuery;
|
||||
var panel = document.getElementById('ookOutput');
|
||||
if (!panel) return;
|
||||
var divs = panel.querySelectorAll('.ook-frame');
|
||||
divs.forEach(function (div) {
|
||||
if (!q) {
|
||||
div.style.display = '';
|
||||
div.style.background = '';
|
||||
return;
|
||||
}
|
||||
var bits = div.dataset.bits || '';
|
||||
var interp = interpretBits(bits, state.bitOrder);
|
||||
var match = interp.hex.includes(q) || interp.ascii.toLowerCase().includes(q);
|
||||
div.style.display = match ? '' : 'none';
|
||||
div.style.background = match ? 'rgba(0,255,136,0.05)' : '';
|
||||
});
|
||||
}
|
||||
|
||||
// ---- UI ----
|
||||
|
||||
function updateUI(running) {
|
||||
var startBtn = document.getElementById('ookStartBtn');
|
||||
var stopBtn = document.getElementById('ookStopBtn');
|
||||
var indicator = document.getElementById('ookStatusIndicator');
|
||||
var statusText = document.getElementById('ookStatusText');
|
||||
|
||||
if (startBtn) startBtn.style.display = running ? 'none' : '';
|
||||
if (stopBtn) stopBtn.style.display = running ? '' : 'none';
|
||||
if (indicator) indicator.style.background = running ? '#00ff88' : 'var(--text-dim)';
|
||||
if (statusText) statusText.textContent = running ? 'Listening' : 'Standby';
|
||||
|
||||
// Keep output panel visible if there are frames to review (even after stopping)
|
||||
var outputPanel = document.getElementById('ookOutputPanel');
|
||||
if (outputPanel) {
|
||||
var showPanel = running || state.frames.length > 0;
|
||||
outputPanel.style.display = showPanel ? 'flex' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
return {
|
||||
init: init,
|
||||
destroy: destroy,
|
||||
start: start,
|
||||
stop: stop,
|
||||
setFreq: setFreq,
|
||||
addPreset: addPreset,
|
||||
removePreset: removePreset,
|
||||
resetPresets: resetPresets,
|
||||
renderPresets: renderPresets,
|
||||
setEncoding: setEncoding,
|
||||
setTiming: setTiming,
|
||||
setBitOrder: setBitOrder,
|
||||
suggestBitOrder: suggestBitOrder,
|
||||
filterFrames: filterFrames,
|
||||
clearOutput: clearOutput,
|
||||
exportLog: exportLog,
|
||||
exportJSON: exportJSON,
|
||||
copyCommand: copyCommand,
|
||||
};
|
||||
})();
|
||||
@@ -287,6 +287,10 @@
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg></span>
|
||||
<span class="mode-name">Morse</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('ook')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h3"/><path d="M19 12h3"/><rect x="5" y="8" width="4" height="8" rx="1"/><rect x="10" y="9" width="4" height="6" rx="1"/><rect x="15" y="7" width="4" height="10" rx="1"/></svg></span>
|
||||
<span class="mode-name">OOK Decoder</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -697,6 +701,8 @@
|
||||
|
||||
{% include 'partials/modes/morse.html' %}
|
||||
|
||||
{% include 'partials/modes/ook.html' %}
|
||||
|
||||
{% include 'partials/modes/space-weather.html' %}
|
||||
|
||||
{% include 'partials/modes/tscm.html' %}
|
||||
@@ -3285,6 +3291,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OOK Decoder Output Panel -->
|
||||
<div id="ookOutputPanel" style="display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; padding: 10px;">
|
||||
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px; display: flex; flex-direction: column; height: 100%; min-height: 0;">
|
||||
<!-- Toolbar row 1: bit order -->
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||||
<span>Decoded Frames <span id="ookStatusBarFrames" style="color: var(--text-dim);">0 frames</span></span>
|
||||
<div style="display: flex; gap: 6px; align-items: center;">
|
||||
<span style="color: var(--text-dim); font-size: 10px;">Bit order:</span>
|
||||
<button class="btn btn-sm btn-ghost" id="ookBitMSB"
|
||||
onclick="OokMode.setBitOrder('msb')"
|
||||
style="background: var(--accent); color: #000;">MSB</button>
|
||||
<button class="btn btn-sm btn-ghost" id="ookBitLSB"
|
||||
onclick="OokMode.setBitOrder('lsb')">LSB</button>
|
||||
<button class="btn btn-sm btn-ghost" onclick="OokMode.suggestBitOrder()"
|
||||
title="Auto-detect best bit order from printable character count">
|
||||
Suggest <span id="ookSuggestLabel" style="font-size:9px; margin-left:2px;"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Toolbar row 2: pattern filter -->
|
||||
<div style="margin-bottom: 6px;">
|
||||
<input type="text" id="ookPatternFilter"
|
||||
placeholder="Filter hex or ASCII..."
|
||||
oninput="OokMode.filterFrames(this.value)"
|
||||
style="width: 100%; background: #111; border: 1px solid #222; border-radius: 3px; color: var(--text-dim); font-family: var(--font-mono); font-size: 10px; padding: 3px 6px; box-sizing: border-box;">
|
||||
</div>
|
||||
<div id="ookOutput" style="flex: 1; min-height: 0; overflow-y: auto; font-family: var(--font-mono); font-size: 10px; color: var(--text-dim);"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="output-content signal-feed" id="output">
|
||||
<div class="placeholder signal-empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
@@ -3356,6 +3392,7 @@
|
||||
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/wefax.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/morse.js') }}?v={{ version }}&r=morse_iq12"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/ook.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/system.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/meteor.js') }}"></script>
|
||||
@@ -3515,6 +3552,7 @@
|
||||
waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' },
|
||||
morse: { label: 'Morse', indicator: 'MORSE', outputTitle: 'CW/Morse Decoder', group: 'signals' },
|
||||
system: { label: 'System', indicator: 'SYSTEM', outputTitle: 'System Health Monitor', group: 'system' },
|
||||
ook: { label: 'OOK Decoder', indicator: 'OOK', outputTitle: 'OOK Signal Decoder', group: 'signals' },
|
||||
};
|
||||
const validModes = new Set(Object.keys(modeCatalog));
|
||||
window.interceptModeCatalog = Object.assign({}, modeCatalog);
|
||||
@@ -4069,6 +4107,7 @@
|
||||
vdl2: () => { if (vdl2MainEventSource) { vdl2MainEventSource.close(); vdl2MainEventSource = null; } },
|
||||
radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } },
|
||||
meteor: () => typeof MeteorScatter !== 'undefined' && MeteorScatter.destroy?.(),
|
||||
ook: () => typeof OokMode !== 'undefined' && OokMode.destroy?.(),
|
||||
};
|
||||
return moduleDestroyMap[mode] || null;
|
||||
}
|
||||
@@ -4363,6 +4402,7 @@
|
||||
document.getElementById('morseMode')?.classList.toggle('active', mode === 'morse');
|
||||
document.getElementById('meteorMode')?.classList.toggle('active', mode === 'meteor');
|
||||
document.getElementById('systemMode')?.classList.toggle('active', mode === 'system');
|
||||
document.getElementById('ookMode')?.classList.toggle('active', mode === 'ook');
|
||||
|
||||
|
||||
const pagerStats = document.getElementById('pagerStats');
|
||||
@@ -4462,6 +4502,8 @@
|
||||
if (morseOutputPanel && mode !== 'morse') morseOutputPanel.style.display = 'none';
|
||||
const morseDiagLog = document.getElementById('morseDiagLog');
|
||||
if (morseDiagLog && mode !== 'morse') morseDiagLog.style.display = 'none';
|
||||
const ookOutputPanel = document.getElementById('ookOutputPanel');
|
||||
if (ookOutputPanel && mode !== 'ook') ookOutputPanel.style.display = 'none';
|
||||
|
||||
// Update output panel title based on mode
|
||||
const outputTitle = document.getElementById('outputTitle');
|
||||
@@ -4503,16 +4545,17 @@
|
||||
// Show RTL-SDR device section for modes that use it
|
||||
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
||||
if (rtlDeviceSection) {
|
||||
rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'morse' || mode === 'radiosonde' || mode === 'meteor') ? 'block' : 'none';
|
||||
rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'morse' || mode === 'radiosonde' || mode === 'meteor' || mode === 'ook') ? 'block' : 'none';
|
||||
// Save original sidebar position of SDR device section (once)
|
||||
if (!rtlDeviceSection._origParent) {
|
||||
rtlDeviceSection._origParent = rtlDeviceSection.parentNode;
|
||||
rtlDeviceSection._origNext = rtlDeviceSection.nextElementSibling;
|
||||
}
|
||||
// For morse/radiosonde/meteor modes, move SDR device section inside the panel after the title
|
||||
// For morse/radiosonde/meteor/ook modes, move SDR device section inside the panel after the title
|
||||
const morsePanel = document.getElementById('morseMode');
|
||||
const radiosondePanel = document.getElementById('radiosondeMode');
|
||||
const meteorPanel = document.getElementById('meteorMode');
|
||||
const ookPanel = document.getElementById('ookMode');
|
||||
if (mode === 'morse' && morsePanel) {
|
||||
const firstSection = morsePanel.querySelector('.section');
|
||||
if (firstSection) firstSection.after(rtlDeviceSection);
|
||||
@@ -4522,6 +4565,9 @@
|
||||
} else if (mode === 'meteor' && meteorPanel) {
|
||||
const firstSection = meteorPanel.querySelector('.section');
|
||||
if (firstSection) firstSection.after(rtlDeviceSection);
|
||||
} else if (mode === 'ook' && ookPanel) {
|
||||
const firstSection = ookPanel.querySelector('.section');
|
||||
if (firstSection) firstSection.after(rtlDeviceSection);
|
||||
} else if (rtlDeviceSection._origParent && rtlDeviceSection.parentNode !== rtlDeviceSection._origParent) {
|
||||
// Restore to original sidebar position when leaving morse mode
|
||||
if (rtlDeviceSection._origNext) {
|
||||
@@ -4541,7 +4587,7 @@
|
||||
// Hide output console for modes with their own visualizations
|
||||
const outputEl = document.getElementById('output');
|
||||
const statusBar = document.querySelector('.status-bar');
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall' || mode === 'morse' || mode === 'meteor' || mode === 'system') ? 'none' : 'block';
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall' || mode === 'morse' || mode === 'meteor' || mode === 'system' || mode === 'ook') ? 'none' : 'block';
|
||||
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'morse' || mode === 'meteor' || mode === 'system') ? 'none' : 'flex';
|
||||
|
||||
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
|
||||
@@ -4626,6 +4672,8 @@
|
||||
MeteorScatter.init();
|
||||
} else if (mode === 'system') {
|
||||
SystemHealth.init();
|
||||
} else if (mode === 'ook') {
|
||||
OokMode.init();
|
||||
}
|
||||
|
||||
// Waterfall destroy is now handled by moduleDestroyMap above.
|
||||
@@ -5639,6 +5687,7 @@
|
||||
let allMessages = [];
|
||||
|
||||
function exportCSV() {
|
||||
if (currentMode === 'ook') { OokMode.exportLog(); return; }
|
||||
if (allMessages.length === 0) {
|
||||
alert('No messages to export');
|
||||
return;
|
||||
@@ -5660,6 +5709,7 @@
|
||||
}
|
||||
|
||||
function exportJSON() {
|
||||
if (currentMode === 'ook') { OokMode.exportJSON(); return; }
|
||||
if (allMessages.length === 0) {
|
||||
alert('No messages to export');
|
||||
return;
|
||||
@@ -6845,6 +6895,7 @@
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
if (currentMode === 'ook') { OokMode.clearOutput(); return; }
|
||||
document.getElementById('output').innerHTML = `
|
||||
<div class="placeholder" style="color: #888; text-align: center; padding: 50px;">
|
||||
Messages cleared. ${isRunning || isSensorRunning ? 'Waiting for new messages...' : 'Start decoding to receive messages.'}
|
||||
@@ -13051,7 +13102,7 @@
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
`;
|
||||
|
||||
// Add "Listen" button for RF signals
|
||||
// Add "Listen" and "Decode (OOK)" buttons for RF signals
|
||||
if (protocol === 'rf' && device.frequency) {
|
||||
const freq = device.frequency;
|
||||
html += `
|
||||
@@ -13061,6 +13112,10 @@
|
||||
<button class="tscm-action-btn" onclick="listenToRfSignal(${freq}, 'am')">
|
||||
Listen (AM)
|
||||
</button>
|
||||
<button class="tscm-action-btn" onclick="decodeWithOok(${freq})"
|
||||
title="Open OOK decoder tuned to this frequency">
|
||||
Decode (OOK)
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -13074,7 +13129,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div style="font-size: 10px; color: var(--text-secondary); margin-top: 8px;">
|
||||
${protocol === 'rf' ? 'Listen buttons open Spectrum Waterfall. ' : ''}Known devices are excluded from threat scoring in future sweeps.
|
||||
${protocol === 'rf' ? 'Listen opens Spectrum Waterfall. Decode (OOK) opens the OOK decoder tuned to this frequency. ' : ''}Known devices are excluded from threat scoring in future sweeps.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -13870,6 +13925,17 @@
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function decodeWithOok(frequency) {
|
||||
// Close the TSCM modal and switch to OOK decoder with the detected frequency pre-filled
|
||||
closeTscmDeviceModal();
|
||||
switchMode('ook');
|
||||
setTimeout(function () {
|
||||
if (typeof OokMode !== 'undefined' && typeof OokMode.setFreq === 'function') {
|
||||
OokMode.setFreq(parseFloat(frequency).toFixed(3));
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function showDevicesByCategory(category) {
|
||||
const modal = document.getElementById('tscmDeviceModal');
|
||||
const content = document.getElementById('tscmDeviceModalContent');
|
||||
|
||||
153
templates/partials/modes/ook.html
Normal file
153
templates/partials/modes/ook.html
Normal file
@@ -0,0 +1,153 @@
|
||||
<!-- OOK SIGNAL DECODER MODE -->
|
||||
<div id="ookMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>OOK Signal Decoder</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||
Decode raw OOK (On-Off Keying) signals via rtl_433 flex decoder.
|
||||
Captures frames with configurable pulse timing and displays raw bits,
|
||||
hex, and attempted ASCII — useful for unknown ISM-band protocols.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Frequency</h3>
|
||||
<div class="form-group">
|
||||
<label>Frequency (MHz)</label>
|
||||
<input type="number" id="ookFrequency" value="433.920" step="0.001" min="1" max="1766">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Presets <span style="font-size:9px; color:#555;">(right-click to remove)</span></label>
|
||||
<div id="ookPresetButtons" style="display: flex; flex-wrap: wrap; gap: 4px;">
|
||||
<!-- Populated by OokMode.renderPresets() -->
|
||||
</div>
|
||||
<div style="margin-top: 6px; display: flex; gap: 4px;">
|
||||
<input type="text" id="ookNewPresetFreq" placeholder="MHz"
|
||||
style="width: 80px; background: #111; border: 1px solid #222; border-radius: 3px; color: var(--text-dim); font-family: var(--font-mono); font-size: 11px; padding: 3px 6px;">
|
||||
<button class="preset-btn" onclick="OokMode.addPreset()" style="background: #2ecc71; color: #000;">Add</button>
|
||||
<button class="preset-btn" onclick="OokMode.resetPresets()" style="font-size: 10px;">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>SDR Settings</h3>
|
||||
<div class="form-group">
|
||||
<label>Gain (dB)</label>
|
||||
<input type="number" id="ookGain" value="40" step="1" min="0" max="50">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>PPM Correction</label>
|
||||
<input type="number" id="ookPPM" value="0" step="1" min="-100" max="100">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Modulation</h3>
|
||||
<div class="form-group">
|
||||
<div style="display: flex; gap: 4px;">
|
||||
<button class="preset-btn" id="ookEnc_pwm"
|
||||
onclick="OokMode.setEncoding('pwm')"
|
||||
style="flex: 1; background: var(--accent); color: #000;">PWM</button>
|
||||
<button class="preset-btn" id="ookEnc_ppm"
|
||||
onclick="OokMode.setEncoding('ppm')"
|
||||
style="flex: 1;">PPM</button>
|
||||
<button class="preset-btn" id="ookEnc_manchester"
|
||||
onclick="OokMode.setEncoding('manchester')"
|
||||
style="flex: 1;">Manchester</button>
|
||||
</div>
|
||||
<input type="hidden" id="ookEncoding" value="pwm">
|
||||
<p id="ookEncodingHint" class="info-text" style="font-size: 10px; color: var(--text-dim); margin-top: 4px;">
|
||||
Short pulse = 0, long pulse = 1. Most common for ISM OOK.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Pulse Timing</h3>
|
||||
<p class="info-text" style="font-size: 10px; color: var(--text-dim); margin-bottom: 8px;">
|
||||
Pulse widths in microseconds for the flex decoder.
|
||||
</p>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||||
<div class="form-group">
|
||||
<label>Short (μs)</label>
|
||||
<input type="number" id="ookShortPulse" value="300" step="10" min="50" max="5000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Long (μs)</label>
|
||||
<input type="number" id="ookLongPulse" value="600" step="10" min="100" max="10000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Gap/Reset (μs)</label>
|
||||
<input type="number" id="ookResetLimit" value="8000" step="100" min="500" max="50000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Gap limit (μs)</label>
|
||||
<input type="number" id="ookGapLimit" value="5000" step="100" min="500" max="50000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Tolerance (μs)</label>
|
||||
<input type="number" id="ookTolerance" value="150" step="10" min="10" max="1000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Min bits</label>
|
||||
<input type="number" id="ookMinBits" value="8" step="1" min="1" max="512">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: 8px;">
|
||||
<label style="font-size: 10px; color: var(--text-dim);">Quick presets (short/long μs)</label>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px;">
|
||||
<button class="preset-btn" onclick="OokMode.setTiming(300,600,8000,5000,150,8)"
|
||||
title="Generic ISM default">300/600</button>
|
||||
<button class="preset-btn" onclick="OokMode.setTiming(300,900,8000,5000,150,16)"
|
||||
title="PWM common variant">300/900</button>
|
||||
<button class="preset-btn" onclick="OokMode.setTiming(400,800,8000,5000,150,16)"
|
||||
title="Generic 2:1 ratio">400/800</button>
|
||||
<button class="preset-btn" onclick="OokMode.setTiming(500,1500,10000,6000,200,16)"
|
||||
title="Long-range keyfob">500/1500</button>
|
||||
<button class="preset-btn" onclick="OokMode.setTiming(500,1000,8000,5000,150,8)"
|
||||
title="Manchester clock period">500 MC</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Options</h3>
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="checkbox" id="ookDeduplicate" style="width: auto; margin: 0;">
|
||||
<span>Deduplicate frames</span>
|
||||
</label>
|
||||
<p class="info-text" style="font-size: 10px; color: var(--text-dim); margin-top: 4px;">
|
||||
Suppress consecutive frames with identical hex content.
|
||||
Useful when a transmitter repeats the same packet multiple times.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="section">
|
||||
<div style="display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim);">
|
||||
<span id="ookStatusIndicator" class="status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim);"></span>
|
||||
<span id="ookStatusText">Standby</span>
|
||||
<span style="margin-left: auto;" id="ookFrameCount">0 frames</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<p class="info-text" style="font-size: 11px; color: #ffaa00; line-height: 1.5;">
|
||||
Uses rtl_433 with a custom flex decoder. Requires rtl_433 installed.
|
||||
Works on any OOK/ASK signal in the SDR's frequency range.
|
||||
</p>
|
||||
<div id="ookCommandDisplay" style="display: none; margin-top: 8px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
||||
<span style="font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">Active Command</span>
|
||||
<button class="btn btn-sm btn-ghost" onclick="OokMode.copyCommand()" title="Copy to clipboard"
|
||||
style="font-size: 9px; padding: 1px 6px;">Copy</button>
|
||||
</div>
|
||||
<pre id="ookCommandText" style="margin: 0; padding: 6px 8px; background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 4px; font-family: var(--font-mono); font-size: 10px; color: var(--text-dim); white-space: pre-wrap; word-break: break-all; line-height: 1.5;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="ookStartBtn" onclick="OokMode.start()">Start Decoder</button>
|
||||
<button class="stop-btn" id="ookStopBtn" onclick="OokMode.stop()" style="display: none;">Stop Decoder</button>
|
||||
</div>
|
||||
@@ -68,6 +68,7 @@
|
||||
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
|
||||
{{ mode_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.4"/><path d="M2 21h20" opacity="0.2"/></svg>') }}
|
||||
{{ mode_item('morse', 'Morse', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg>') }}
|
||||
{{ mode_item('ook', 'OOK Decoder', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h3"/><path d="M19 12h3"/><rect x="5" y="8" width="4" height="8" rx="1"/><rect x="10" y="9" width="4" height="6" rx="1"/><rect x="15" y="7" width="4" height="10" rx="1"/></svg>') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -218,6 +219,7 @@
|
||||
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
|
||||
{{ mobile_item('morse', 'Morse', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg>') }}
|
||||
{{ mobile_item('ook', 'OOK', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h3"/><path d="M19 12h3"/><rect x="5" y="8" width="4" height="8" rx="1"/><rect x="10" y="9" width="4" height="6" rx="1"/><rect x="15" y="7" width="4" height="10" rx="1"/></svg>') }}
|
||||
{# Tracking #}
|
||||
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
|
||||
{{ mobile_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
|
||||
|
||||
217
utils/ook.py
Normal file
217
utils/ook.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user