fix(ook): harden for upstream review — tests, cleanup, CSS extraction

- Add kill_all() handler for OOK process cleanup on global reset
- Fix stop_ook() to close pipes and join parser thread (prevents hangs)
- Add ook.css with CSS classes, replace inline styles in ook.html
- Register ook.css in lazy-load style map (INTERCEPT_MODE_STYLE_MAP)
- Fix frontend frequency min=24 to match backend validation
- Add 22 unit tests for decode_ook_frame, ook_parser_thread, and routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
thatsatechnique
2026-03-04 14:52:32 -08:00
parent 93fb694e25
commit 18db66bce3
6 changed files with 413 additions and 27 deletions

9
app.py
View File

@@ -832,7 +832,7 @@ def health_check() -> Response:
def kill_all() -> Response: def kill_all() -> Response:
"""Kill all decoder, WiFi, and Bluetooth processes.""" """Kill all decoder, WiFi, and Bluetooth processes."""
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
global vdl2_process, morse_process, radiosonde_process global vdl2_process, morse_process, radiosonde_process, ook_process
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
# Import modules to reset their state # Import modules to reset their state
@@ -896,6 +896,13 @@ def kill_all() -> Response:
with morse_lock: with morse_lock:
morse_process = None morse_process = None
# Reset OOK state
with ook_lock:
if ook_process:
safe_terminate(ook_process)
unregister_process(ook_process)
ook_process = None
# Reset APRS state # Reset APRS state
with aprs_lock: with aprs_lock:
aprs_process = None aprs_process = None

View File

@@ -232,20 +232,39 @@ def start_ook() -> Response:
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': 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()
@ook_bp.route('/ook/stop', methods=['POST']) @ook_bp.route('/ook/stop', methods=['POST'])
def stop_ook() -> Response: def stop_ook() -> Response:
global ook_active_device global ook_active_device
with app_module.ook_lock: with app_module.ook_lock:
if app_module.ook_process: if app_module.ook_process:
stop_event = getattr(app_module.ook_process, '_stop_parser', None) proc = app_module.ook_process
stop_event = getattr(proc, '_stop_parser', None)
parser_thread = getattr(proc, '_parser_thread', None)
# Signal parser thread to stop
if stop_event: if stop_event:
stop_event.set() stop_event.set()
safe_terminate(app_module.ook_process) # Close pipes so parser thread unblocks from readline()
unregister_process(app_module.ook_process) _close_pipe(getattr(proc, 'stdout', None))
_close_pipe(getattr(proc, 'stderr', None))
safe_terminate(proc)
unregister_process(proc)
app_module.ook_process = None app_module.ook_process = None
# Join parser thread with timeout
if parser_thread:
parser_thread.join(timeout=0.5)
if ook_active_device is not None: if ook_active_device is not None:
app_module.release_sdr_device(ook_active_device) app_module.release_sdr_device(ook_active_device)
ook_active_device = None ook_active_device = None

110
static/css/modes/ook.css Normal file
View File

@@ -0,0 +1,110 @@
/* OOK Signal Decoder Styles */
.ook-presets {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.ook-preset-add {
margin-top: 6px;
display: flex;
gap: 4px;
}
.ook-preset-add input {
width: 80px;
background: var(--bg-tertiary, #111);
border: 1px solid var(--border-color, #222);
border-radius: 3px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
padding: 3px 6px;
}
.ook-preset-hint {
font-size: 9px;
color: var(--text-muted, #555);
}
.ook-encoding-btns {
display: flex;
gap: 4px;
}
.ook-encoding-btns .preset-btn {
flex: 1;
}
.ook-timing-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.ook-timing-presets {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.ook-status-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-dim);
}
.ook-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-dim);
}
.ook-warning {
font-size: 11px;
color: #ffaa00;
line-height: 1.5;
}
.ook-command-display {
display: none;
margin-top: 8px;
}
.ook-command-label {
font-size: 10px;
color: var(--text-muted, #555);
text-transform: uppercase;
letter-spacing: 1px;
}
.ook-command-text {
margin: 0;
padding: 6px 8px;
background: var(--bg-deep, #0a0a0a);
border: 1px solid var(--border-color, #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;
}
.ook-dedup-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.ook-dedup-label input[type="checkbox"] {
width: auto;
margin: 0;
}

View File

@@ -86,7 +86,8 @@
morse: "{{ url_for('static', filename='css/modes/morse.css') }}", morse: "{{ url_for('static', filename='css/modes/morse.css') }}",
radiosonde: "{{ url_for('static', filename='css/modes/radiosonde.css') }}", radiosonde: "{{ url_for('static', filename='css/modes/radiosonde.css') }}",
meteor: "{{ url_for('static', filename='css/modes/meteor.css') }}", meteor: "{{ url_for('static', filename='css/modes/meteor.css') }}",
system: "{{ url_for('static', filename='css/modes/system.css') }}" system: "{{ url_for('static', filename='css/modes/system.css') }}",
ook: "{{ url_for('static', filename='css/modes/ook.css') }}"
}; };
window.INTERCEPT_MODE_STYLE_LOADED = {}; window.INTERCEPT_MODE_STYLE_LOADED = {};
window.INTERCEPT_MODE_STYLE_PROMISES = {}; window.INTERCEPT_MODE_STYLE_PROMISES = {};

View File

@@ -13,16 +13,15 @@
<h3>Frequency</h3> <h3>Frequency</h3>
<div class="form-group"> <div class="form-group">
<label>Frequency (MHz)</label> <label>Frequency (MHz)</label>
<input type="number" id="ookFrequency" value="433.920" step="0.001" min="1" max="1766"> <input type="number" id="ookFrequency" value="433.920" step="0.001" min="24" max="1766">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Presets <span style="font-size:9px; color:#555;">(right-click to remove)</span></label> <label>Presets <span class="ook-preset-hint">(right-click to remove)</span></label>
<div id="ookPresetButtons" style="display: flex; flex-wrap: wrap; gap: 4px;"> <div id="ookPresetButtons" class="ook-presets">
<!-- Populated by OokMode.renderPresets() --> <!-- Populated by OokMode.renderPresets() -->
</div> </div>
<div style="margin-top: 6px; display: flex; gap: 4px;"> <div class="ook-preset-add">
<input type="text" id="ookNewPresetFreq" placeholder="MHz" <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.addPreset()" style="background: #2ecc71; color: #000;">Add</button>
<button class="preset-btn" onclick="OokMode.resetPresets()" style="font-size: 10px;">Reset</button> <button class="preset-btn" onclick="OokMode.resetPresets()" style="font-size: 10px;">Reset</button>
</div> </div>
@@ -44,16 +43,14 @@
<div class="section"> <div class="section">
<h3>Modulation</h3> <h3>Modulation</h3>
<div class="form-group"> <div class="form-group">
<div style="display: flex; gap: 4px;"> <div class="ook-encoding-btns">
<button class="preset-btn" id="ookEnc_pwm" <button class="preset-btn" id="ookEnc_pwm"
onclick="OokMode.setEncoding('pwm')" onclick="OokMode.setEncoding('pwm')"
style="flex: 1; background: var(--accent); color: #000;">PWM</button> style="background: var(--accent); color: #000;">PWM</button>
<button class="preset-btn" id="ookEnc_ppm" <button class="preset-btn" id="ookEnc_ppm"
onclick="OokMode.setEncoding('ppm')" onclick="OokMode.setEncoding('ppm')">PPM</button>
style="flex: 1;">PPM</button>
<button class="preset-btn" id="ookEnc_manchester" <button class="preset-btn" id="ookEnc_manchester"
onclick="OokMode.setEncoding('manchester')" onclick="OokMode.setEncoding('manchester')">Manchester</button>
style="flex: 1;">Manchester</button>
</div> </div>
<input type="hidden" id="ookEncoding" value="pwm"> <input type="hidden" id="ookEncoding" value="pwm">
<p id="ookEncodingHint" class="info-text" style="font-size: 10px; color: var(--text-dim); margin-top: 4px;"> <p id="ookEncodingHint" class="info-text" style="font-size: 10px; color: var(--text-dim); margin-top: 4px;">
@@ -67,7 +64,7 @@
<p class="info-text" style="font-size: 10px; color: var(--text-dim); margin-bottom: 8px;"> <p class="info-text" style="font-size: 10px; color: var(--text-dim); margin-bottom: 8px;">
Pulse widths in microseconds for the flex decoder. Pulse widths in microseconds for the flex decoder.
</p> </p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;"> <div class="ook-timing-grid">
<div class="form-group"> <div class="form-group">
<label>Short (&mu;s)</label> <label>Short (&mu;s)</label>
<input type="number" id="ookShortPulse" value="300" step="10" min="50" max="5000"> <input type="number" id="ookShortPulse" value="300" step="10" min="50" max="5000">
@@ -95,7 +92,7 @@
</div> </div>
<div class="form-group" style="margin-top: 8px;"> <div class="form-group" style="margin-top: 8px;">
<label style="font-size: 10px; color: var(--text-dim);">Quick presets (short/long &mu;s)</label> <label style="font-size: 10px; color: var(--text-dim);">Quick presets (short/long &mu;s)</label>
<div style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px;"> <div class="ook-timing-presets">
<button class="preset-btn" onclick="OokMode.setTiming(300,600,8000,5000,150,8)" <button class="preset-btn" onclick="OokMode.setTiming(300,600,8000,5000,150,8)"
title="Generic ISM default">300/600</button> title="Generic ISM default">300/600</button>
<button class="preset-btn" onclick="OokMode.setTiming(300,900,8000,5000,150,16)" <button class="preset-btn" onclick="OokMode.setTiming(300,900,8000,5000,150,16)"
@@ -113,8 +110,8 @@
<div class="section"> <div class="section">
<h3>Options</h3> <h3>Options</h3>
<div class="form-group"> <div class="form-group">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;"> <label class="ook-dedup-label">
<input type="checkbox" id="ookDeduplicate" style="width: auto; margin: 0;"> <input type="checkbox" id="ookDeduplicate">
<span>Deduplicate frames</span> <span>Deduplicate frames</span>
</label> </label>
<p class="info-text" style="font-size: 10px; color: var(--text-dim); margin-top: 4px;"> <p class="info-text" style="font-size: 10px; color: var(--text-dim); margin-top: 4px;">
@@ -126,25 +123,25 @@
<!-- Status --> <!-- Status -->
<div class="section"> <div class="section">
<div style="display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim);"> <div class="ook-status-row">
<span id="ookStatusIndicator" class="status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim);"></span> <span id="ookStatusIndicator" class="ook-status-dot"></span>
<span id="ookStatusText">Standby</span> <span id="ookStatusText">Standby</span>
<span style="margin-left: auto;" id="ookFrameCount">0 frames</span> <span style="margin-left: auto;" id="ookFrameCount">0 frames</span>
</div> </div>
</div> </div>
<div class="section"> <div class="section">
<p class="info-text" style="font-size: 11px; color: #ffaa00; line-height: 1.5;"> <p class="info-text ook-warning">
Uses rtl_433 with a custom flex decoder. Requires rtl_433 installed. Uses rtl_433 with a custom flex decoder. Requires rtl_433 installed.
Works on any OOK/ASK signal in the SDR's frequency range. Works on any OOK/ASK signal in the SDR's frequency range.
</p> </p>
<div id="ookCommandDisplay" style="display: none; margin-top: 8px;"> <div id="ookCommandDisplay" class="ook-command-display">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;"> <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> <span class="ook-command-label">Active Command</span>
<button class="btn btn-sm btn-ghost" onclick="OokMode.copyCommand()" title="Copy to clipboard" <button class="btn btn-sm btn-ghost" onclick="OokMode.copyCommand()" title="Copy to clipboard"
style="font-size: 9px; padding: 1px 6px;">Copy</button> style="font-size: 9px; padding: 1px 6px;">Copy</button>
</div> </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> <pre id="ookCommandText" class="ook-command-text"></pre>
</div> </div>
</div> </div>

252
tests/test_ook.py Normal file
View File

@@ -0,0 +1,252 @@
"""Tests for OOK signal decoder utilities and route handlers."""
from __future__ import annotations
import io
import json
import queue
import threading
import pytest
from utils.ook import decode_ook_frame, ook_parser_thread
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _login_session(client) -> None:
"""Mark the Flask test session as authenticated."""
with client.session_transaction() as sess:
sess['logged_in'] = True
sess['username'] = 'test'
sess['role'] = 'admin'
# ---------------------------------------------------------------------------
# decode_ook_frame
# ---------------------------------------------------------------------------
class TestDecodeOokFrame:
def test_valid_hex_returns_bits_and_hex(self):
result = decode_ook_frame('aa55')
assert result is not None
assert result['hex'] == 'aa55'
assert result['bits'] == '1010101001010101'
assert result['byte_count'] == 2
assert result['bit_count'] == 16
def test_strips_0x_prefix(self):
result = decode_ook_frame('0xaa55')
assert result is not None
assert result['hex'] == 'aa55'
def test_strips_0X_uppercase_prefix(self):
result = decode_ook_frame('0Xff')
assert result is not None
assert result['hex'] == 'ff'
assert result['bits'] == '11111111'
def test_strips_spaces(self):
result = decode_ook_frame('aa 55')
assert result is not None
assert result['hex'] == 'aa55'
def test_invalid_hex_returns_none(self):
assert decode_ook_frame('zzzz') is None
def test_empty_string_returns_none(self):
assert decode_ook_frame('') is None
def test_just_0x_prefix_returns_none(self):
assert decode_ook_frame('0x') is None
def test_single_byte(self):
result = decode_ook_frame('48')
assert result is not None
assert result['bits'] == '01001000'
assert result['byte_count'] == 1
def test_hello_ascii(self):
"""'Hello' in hex is 48656c6c6f."""
result = decode_ook_frame('48656c6c6f')
assert result is not None
assert result['hex'] == '48656c6c6f'
assert result['byte_count'] == 5
assert result['bit_count'] == 40
# ---------------------------------------------------------------------------
# ook_parser_thread
# ---------------------------------------------------------------------------
class TestOokParserThread:
def _run_parser(self, json_lines, encoding='pwm', deduplicate=False):
"""Feed JSON lines to parser thread and collect output events."""
raw = '\n'.join(json.dumps(line) for line in json_lines) + '\n'
stdout = io.BytesIO(raw.encode('utf-8'))
output_queue = queue.Queue()
stop_event = threading.Event()
t = threading.Thread(
target=ook_parser_thread,
args=(stdout, output_queue, stop_event, encoding, deduplicate),
)
t.start()
t.join(timeout=2)
events = []
while not output_queue.empty():
events.append(output_queue.get_nowait())
return events
def test_parses_codes_field_list(self):
events = self._run_parser([{'codes': ['aa55']}])
frames = [e for e in events if e.get('type') == 'ook_frame']
assert len(frames) == 1
assert frames[0]['hex'] == 'aa55'
assert frames[0]['bits'] == '1010101001010101'
assert frames[0]['inverted'] is False
def test_parses_codes_field_string(self):
events = self._run_parser([{'codes': 'ff00'}])
frames = [e for e in events if e.get('type') == 'ook_frame']
assert len(frames) == 1
assert frames[0]['hex'] == 'ff00'
def test_parses_code_field(self):
events = self._run_parser([{'code': 'abcd'}])
frames = [e for e in events if e.get('type') == 'ook_frame']
assert len(frames) == 1
assert frames[0]['hex'] == 'abcd'
def test_parses_data_field(self):
events = self._run_parser([{'data': '1234'}])
frames = [e for e in events if e.get('type') == 'ook_frame']
assert len(frames) == 1
assert frames[0]['hex'] == '1234'
def test_strips_brace_bit_count_prefix(self):
"""rtl_433 sometimes prefixes with {N} bit count."""
events = self._run_parser([{'codes': ['{16}aa55']}])
frames = [e for e in events if e.get('type') == 'ook_frame']
assert len(frames) == 1
assert frames[0]['hex'] == 'aa55'
def test_deduplication_suppresses_consecutive_identical(self):
events = self._run_parser(
[{'codes': ['aa55']}, {'codes': ['aa55']}, {'codes': ['aa55']}],
deduplicate=True,
)
frames = [e for e in events if e.get('type') == 'ook_frame']
assert len(frames) == 1
def test_deduplication_allows_different_frames(self):
events = self._run_parser(
[{'codes': ['aa55']}, {'codes': ['ff00']}, {'codes': ['aa55']}],
deduplicate=True,
)
frames = [e for e in events if e.get('type') == 'ook_frame']
assert len(frames) == 3
def test_no_code_field_emits_ook_raw(self):
events = self._run_parser([{'model': 'unknown', 'id': 42}])
raw_events = [e for e in events if e.get('type') == 'ook_raw']
assert len(raw_events) == 1
def test_rssi_extracted_from_snr(self):
events = self._run_parser([{'codes': ['aa55'], 'snr': 12.3}])
frames = [e for e in events if e.get('type') == 'ook_frame']
assert len(frames) == 1
assert frames[0]['rssi'] == 12.3
def test_encoding_passed_through(self):
events = self._run_parser([{'codes': ['aa55']}], encoding='manchester')
frames = [e for e in events if e.get('type') == 'ook_frame']
assert frames[0]['encoding'] == 'manchester'
def test_timestamp_present(self):
events = self._run_parser([{'codes': ['aa55']}])
frames = [e for e in events if e.get('type') == 'ook_frame']
assert 'timestamp' in frames[0]
assert len(frames[0]['timestamp']) > 0
def test_invalid_json_skipped(self):
"""Non-JSON lines should be silently skipped."""
raw = b'not json\n{"codes": ["aa55"]}\n'
stdout = io.BytesIO(raw)
output_queue = queue.Queue()
stop_event = threading.Event()
t = threading.Thread(
target=ook_parser_thread,
args=(stdout, output_queue, stop_event),
)
t.start()
t.join(timeout=2)
events = []
while not output_queue.empty():
events.append(output_queue.get_nowait())
frames = [e for e in events if e.get('type') == 'ook_frame']
assert len(frames) == 1
# ---------------------------------------------------------------------------
# Route handlers
# ---------------------------------------------------------------------------
class TestOokRoutes:
@pytest.fixture
def client(self):
import app as app_module
from routes import register_blueprints
app_module.app.config['TESTING'] = True
if 'ook' not in app_module.app.blueprints:
register_blueprints(app_module.app)
with app_module.app.test_client() as c:
yield c
def test_status_returns_not_running(self, client):
_login_session(client)
resp = client.get('/ook/status')
assert resp.status_code == 200
data = resp.get_json()
assert data['running'] is False
def test_stop_when_not_running(self, client):
_login_session(client)
resp = client.post('/ook/stop')
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'not_running'
def test_start_validates_frequency(self, client):
_login_session(client)
resp = client.post('/ook/start',
json={'frequency': 'invalid'},
content_type='application/json')
assert resp.status_code == 400
def test_start_validates_encoding(self, client):
_login_session(client)
resp = client.post('/ook/start',
json={'encoding': 'invalid_enc'},
content_type='application/json')
assert resp.status_code == 400
def test_start_validates_timing_params(self, client):
_login_session(client)
resp = client.post('/ook/start',
json={'short_pulse': 'not_a_number'},
content_type='application/json')
assert resp.status_code == 400
def test_start_rejects_negative_frequency(self, client):
_login_session(client)
resp = client.post('/ook/start',
json={'frequency': '-5'},
content_type='application/json')
assert resp.status_code == 400