From 7a04dbdec950caaaf11960ccdf738cb3891dabeb Mon Sep 17 00:00:00 2001 From: Andrew Berry Date: Tue, 9 Jun 2026 07:28:34 -0400 Subject: [PATCH] test: Add HTTP/WS server test coverage Add black-box integration tests for the HTTP/WS server and rtl_tcp, plus a gcov coverage helper and supporting CI wiring. Refs: #3541 Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/check.yml | 22 ++++ .gitignore | 6 ++ CMakeLists.txt | 18 ++++ tests/CMakeLists.txt | 30 ++++++ tests/coverage.sh | 62 ++++++++++++ tests/http-integration-test.sh | 178 +++++++++++++++++++++++++++++++++ tests/http-rtltcp-test.sh | 141 ++++++++++++++++++++++++++ tests/rtl_tcp_serve.py | 137 +++++++++++++++++++++++++ tests/ws-probe.py | 161 +++++++++++++++++++++++++++++ 9 files changed, 755 insertions(+) create mode 100755 tests/coverage.sh create mode 100755 tests/http-integration-test.sh create mode 100755 tests/http-rtltcp-test.sh create mode 100755 tests/rtl_tcp_serve.py create mode 100755 tests/ws-probe.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index b138040b..b69d2f2b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -78,6 +78,28 @@ jobs: run: | ./tests/symbolizer.py check + coverage_check_job: + runs-on: ubuntu-latest + name: Run tests with code coverage + steps: + - uses: actions/checkout@v5 + - name: Setup + run: | + sudo apt-get update -q -y + sudo apt-get install -q -y --no-install-recommends cmake gcovr curl python3 + - name: Build with coverage and run tests + # Builds with -DENABLE_COVERAGE=ON, runs ctest (incl. the HTTP server + # integration/dataflow/ws tests), and writes an HTML report. Exits + # non-zero if any test fails. + run: ./tests/coverage.sh "$GITHUB_WORKSPACE/build-coverage" + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: build-coverage/coverage-report + if-no-files-found: warn + analyzer_check_job: # https://github.com/actions/virtual-environments # - Ubuntu 24.04 ubuntu-24.04 diff --git a/.gitignore b/.gitignore index 431205f2..53a8634e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ # CMake out-of-tree builds build*/ +# Code coverage artifacts (see tests/coverage.sh) +*.gcda +*.gcno +*.gcov +coverage-report/ + # IDE files .cproject .settings diff --git a/CMakeLists.txt b/CMakeLists.txt index 46b1f3dc..0a016286 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -112,6 +112,24 @@ if("${CMAKE_C_COMPILER_ID}" STREQUAL "GNU" AND NOT "13.2.0" VERSION_GREATER CMAK add_definitions(-fanalyzer) endif() +# Optional code coverage instrumentation (gcov/llvm-cov), for use with BUILD_TESTING. +# Configure with -DENABLE_COVERAGE=ON, run `ctest`, then generate a report with +# gcov/gcovr/lcov (see tests/coverage.sh). Forces -O0 and disables inlining so +# coverage maps cleanly back to source lines. +option(ENABLE_COVERAGE "Build with code coverage instrumentation (GCC/Clang)" OFF) +if(ENABLE_COVERAGE) + if("${CMAKE_C_COMPILER_ID}" MATCHES "GNU|Clang") + message(STATUS "Code coverage instrumentation enabled") + add_compile_options(--coverage -O0 -g -fno-inline) + # --coverage on the link line pulls in the gcov runtime; set on the flag + # vars rather than add_link_options() to keep cmake_minimum_required (3.10). + set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} --coverage") + set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} --coverage") + else() + message(WARNING "ENABLE_COVERAGE is only supported on GCC/Clang, ignoring") + endif() +endif() + # Shut MSVC up about strdup and strtok if(MSVC) ADD_DEFINITIONS(-D_CRT_NONSTDC_NO_DEPRECATE) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c5a2d082..b76213ca 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -34,6 +34,36 @@ endforeach(testSrc) ######################################################################## add_test(rtl_433_help ../src/rtl_433 -h) +# Black-box test of the HTTP/WS API server. Starts rtl_433 in manual device +# mode (no SDR), exercises each endpoint, and shuts it down cleanly. Skipped +# on Windows (POSIX shell script) and when python3/curl are unavailable. +if(UNIX) + find_program(BASH_PROGRAM sh) + find_program(CURL_PROGRAM curl) + find_program(PYTHON3_PROGRAM python3) + if(BASH_PROGRAM AND CURL_PROGRAM) + add_test( + NAME http_server_integration + COMMAND ${BASH_PROGRAM} + ${CMAKE_CURRENT_SOURCE_DIR}/http-integration-test.sh + $) + # Feeds a synthetic OOK signal over a fake rtl_tcp server so rtl_433 runs + # its live poll loop, then reads the decode back over the WS API. Needs + # python3 for the rtl_tcp server and ws probe. + if(PYTHON3_PROGRAM) + add_test( + NAME http_server_rtltcp + COMMAND ${BASH_PROGRAM} + ${CMAKE_CURRENT_SOURCE_DIR}/http-rtltcp-test.sh + $) + else() + message(STATUS "Skipping http_server_rtltcp test (need python3)") + endif() + else() + message(STATUS "Skipping http_server_integration test (need sh and curl)") + endif() +endif() + ######################################################################## # Define style checks ######################################################################## diff --git a/tests/coverage.sh b/tests/coverage.sh new file mode 100755 index 00000000..e81c3cae --- /dev/null +++ b/tests/coverage.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# +# Configure, build, and run the test suite with code-coverage instrumentation, +# then produce a coverage report. Focuses the report on the HTTP server but +# gcovr/lcov see the whole tree. +# +# Requires gcovr (apt install gcovr, or pip install gcovr) for the HTML/text +# report; falls back to a raw `gcov` summary if gcovr is not installed. +# +# Usage: tests/coverage.sh [build-dir] +# build-dir defaults to ./build-coverage + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +SRC_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +BUILD_DIR=${1:-"$SRC_DIR/build-coverage"} + +echo ">> Configuring coverage build in $BUILD_DIR" +cmake -S "$SRC_DIR" -B "$BUILD_DIR" \ + -DENABLE_COVERAGE=ON \ + -DBUILD_TESTING=ON \ + -DENABLE_RTLSDR=OFF \ + -DENABLE_SOAPYSDR=OFF \ + -DCMAKE_BUILD_TYPE=None + +echo ">> Building" +cmake --build "$BUILD_DIR" -j"$(nproc 2>/dev/null || echo 2)" + +echo ">> Running tests (ctest)" +# Don't abort the report if a test fails; we still want coverage of what ran, +# but remember the status so we can propagate it as our exit code (CI should +# fail on a test failure even though the report is still produced/uploaded). +ctest_rc=0 +( cd "$BUILD_DIR" && ctest --output-on-failure ) || ctest_rc=$? +[ "$ctest_rc" -eq 0 ] || echo ">> (some tests failed, rc=$ctest_rc; continuing to report)" + +echo ">> Generating coverage report" +if command -v gcovr >/dev/null 2>&1; then + mkdir -p "$BUILD_DIR/coverage-report" + # Whole-project summary to the terminal... + gcovr --root "$SRC_DIR" --object-directory "$BUILD_DIR" \ + --exclude '.*/mongoose\.c' --exclude '.*/jsmn\.c' --print-summary \ + --html-details "$BUILD_DIR/coverage-report/index.html" + echo + echo ">> HTTP server coverage:" + gcovr --root "$SRC_DIR" --object-directory "$BUILD_DIR" \ + --filter '.*/http_server\.c' --txt + echo ">> Full HTML report: $BUILD_DIR/coverage-report/index.html" +else + echo ">> gcovr not found; install it (apt: 'apt-get install gcovr', or 'pip install gcovr') for a nice report." >&2 + echo ">> Falling back to raw gcov on http_server.c:" + GCDA=$(find "$BUILD_DIR" -name 'http_server.c.gcda' | head -1) + if [ -n "$GCDA" ]; then + ( cd "$(dirname "$GCDA")" && gcov -b http_server.c.gcno | sed -n '1,12p' ) + else + echo ">> No http_server.c.gcda found - did the server exit cleanly?" >&2 + fi +fi + +# Propagate the test result so CI fails on a test failure (after reporting). +exit "$ctest_rc" diff --git a/tests/http-integration-test.sh b/tests/http-integration-test.sh new file mode 100755 index 00000000..a18f83a2 --- /dev/null +++ b/tests/http-integration-test.sh @@ -0,0 +1,178 @@ +#!/bin/sh +# +# Black-box integration test for the rtl_433 HTTP/WS API server. +# +# Starts rtl_433 in "manual" device mode (-D manual), which runs the HTTP +# server event loop without any SDR hardware or input file, exercises each +# endpoint over the loopback interface, then shuts the server down with +# SIGTERM so it exits cleanly (this is what lets gcov flush .gcda files when +# the binary was built with -DENABLE_COVERAGE=ON). +# +# Usage: http-integration-test.sh [path-to-rtl_433-binary] +# Defaults to ../src/rtl_433 relative to this script. +# Override the port with PORT=NNNN, otherwise a free port is chosen. + +set -u + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +RTL_433=${1:-"$SCRIPT_DIR/../src/rtl_433"} +HOST=127.0.0.1 + +if [ ! -x "$RTL_433" ]; then + echo "ERROR: rtl_433 binary not found or not executable: $RTL_433" >&2 + exit 99 +fi + +# Pick a free TCP port unless one was given, so parallel ctest runs don't collide. +if [ -z "${PORT:-}" ]; then + PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()' 2>/dev/null) +fi +PORT=${PORT:-18433} +BASE="http://$HOST:$PORT" + +SERVER_LOG=$(mktemp 2>/dev/null || echo /tmp/rtl_433_http_test.$$.log) +SERVER_PID="" +FAILED=0 +PASSED=0 + +cleanup() { + if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then + # SIGTERM -> clean shutdown -> atexit/gcov flush. Do NOT use SIGKILL. + kill -TERM "$SERVER_PID" 2>/dev/null + # give it a moment to drain and write coverage data + for _ in 1 2 3 4 5 6 7 8 9 10; do + kill -0 "$SERVER_PID" 2>/dev/null || break + sleep 0.3 + done + kill -0 "$SERVER_PID" 2>/dev/null && kill -KILL "$SERVER_PID" 2>/dev/null + wait "$SERVER_PID" 2>/dev/null + fi + rm -f "$SERVER_LOG" +} +trap cleanup EXIT INT TERM + +fail() { echo " NOT OK: $1" >&2; FAILED=$((FAILED + 1)); } +pass() { echo " ok: $1"; PASSED=$((PASSED + 1)); } + +# expect_status DESC EXPECTED_CODE curl-args... +expect_status() { + desc=$1; want=$2; shift 2 + got=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$@") + [ "$got" = "$want" ] && pass "$desc (HTTP $got)" || fail "$desc: expected HTTP $want, got $got" +} + +# expect_body_contains DESC SUBSTRING curl-args... +expect_body_contains() { + desc=$1; needle=$2; shift 2 + body=$(curl -s --max-time 10 "$@") + case "$body" in + *"$needle"*) pass "$desc (body contains '$needle')" ;; + *) fail "$desc: body did not contain '$needle'"; printf ' got: %.200s\n' "$body" >&2 ;; + esac +} + +# expect_stream DESC URL -- streaming endpoints stay open; we only check the +# response headers, tolerating the curl timeout (exit 28) that follows. +expect_stream() { + desc=$1; url=$2 + hdr=$(curl -s -D - -o /dev/null --max-time 2 "$url" 2>/dev/null) + case "$hdr" in + *"200 OK"*) pass "$desc (200, stream open)" ;; + *) fail "$desc: no '200 OK' in response headers"; printf ' got: %.200s\n' "$hdr" >&2 ;; + esac +} + +echo "Starting rtl_433 HTTP server: $RTL_433 -D manual -F $BASE" +"$RTL_433" -D manual -F "http://$HOST:$PORT" >"$SERVER_LOG" 2>&1 & +SERVER_PID=$! + +# Wait for readiness (server prints/serves once the manual loop is up). +ready=0 +for _ in $(seq 1 50); do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "ERROR: server exited during startup" >&2 + cat "$SERVER_LOG" >&2 + exit 98 + fi + if curl -s -o /dev/null --max-time 1 "$BASE/"; then + ready=1 + break + fi + sleep 0.2 +done +if [ "$ready" != 1 ]; then + echo "ERROR: server did not become ready on $BASE" >&2 + cat "$SERVER_LOG" >&2 + exit 97 +fi + +echo "Server ready on port $PORT (pid $SERVER_PID). Running checks:" + +# --- static / UI --- +expect_status "GET / returns 200" 200 "$BASE/" +expect_body_contains "GET / serves index html" "DOCTYPE html" "$BASE/" +expect_status "OPTIONS / CORS preflight" 204 -X OPTIONS "$BASE/" +expect_status "GET /ui redirects" 307 "$BASE/ui" + +# --- OpenMetrics / Prometheus --- +expect_status "GET /metrics returns 200" 200 "$BASE/metrics" +expect_body_contains "GET /metrics is OpenMetrics" "# TYPE uptime_seconds counter" "$BASE/metrics" +# Non-GET is rejected; send an explicit (empty) body so mongoose frames the +# request rather than waiting for a body that curl's bare -X POST never sends. +expect_status "POST /metrics is rejected" 405 -d '' "$BASE/metrics" + +# --- /cmd RPC (GET query string and POST form) --- +expect_body_contains "GET /cmd?cmd=get_meta" "\"samp_rate\"" "$BASE/cmd?cmd=get_meta" +expect_status "GET /cmd?cmd=get_sample_rate" 200 "$BASE/cmd?cmd=get_sample_rate" +expect_body_contains "POST /cmd form get_meta" "\"samp_rate\"" -d "cmd=get_meta" "$BASE/cmd" +expect_status "GET /cmd with no cmd" 200 "$BASE/cmd" + +# --- more RPC methods, to exercise the rpc_exec getter/setter branches --- +expect_body_contains "GET /cmd get_protocols" "Nice" "$BASE/cmd?cmd=get_protocols" +expect_status "GET /cmd get_stats" 200 "$BASE/cmd?cmd=get_stats" +expect_status "GET /cmd get_center_frequency" 200 "$BASE/cmd?cmd=get_center_frequency" +expect_status "GET /cmd setter center_frequency" 200 "$BASE/cmd?cmd=center_frequency&val=433920000" +expect_body_contains "GET /cmd unknown method is rejected" "Unknown method" "$BASE/cmd?cmd=no_such_method" + +# --- JSON-RPC --- +expect_status "POST /jsonrpc valid method" 200 \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"get_sample_rate"}' "$BASE/jsonrpc" +expect_body_contains "POST /jsonrpc method with params" "result" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"sample_rate","params":[1024000]}' "$BASE/jsonrpc" +expect_status "POST /jsonrpc malformed body does not crash" 200 \ + -H 'Content-Type: application/json' -d 'not json at all' "$BASE/jsonrpc" + +# --- streaming endpoints --- +expect_stream "GET /stream opens" "$BASE/stream" +expect_stream "GET /events opens" "$BASE/events" + +# --- WebSocket: handshake, meta push, and an RPC round-trip --- +if command -v python3 >/dev/null 2>&1; then + if ws_out=$(python3 "$SCRIPT_DIR/ws-probe.py" "$HOST" "$PORT" 10 2>&1); then + pass "websocket handshake + meta + rpc round-trip" + else + fail "websocket probe failed" + printf '%s\n' "$ws_out" | sed 's/^/ /' >&2 + fi +else + echo " skip: websocket probe (python3 not found)" +fi + +# --- robustness: unknown path must not crash the server --- +curl -s -o /dev/null --max-time 2 "$BASE/no/such/path" 2>/dev/null +if kill -0 "$SERVER_PID" 2>/dev/null; then + pass "server still alive after unknown path" +else + fail "server died after request to unknown path" +fi + +echo +echo "Results: $PASSED passed, $FAILED failed" +if [ "$FAILED" -ne 0 ]; then + echo "--- server log ---" >&2 + cat "$SERVER_LOG" >&2 + exit 1 +fi +exit 0 diff --git a/tests/http-rtltcp-test.sh b/tests/http-rtltcp-test.sh new file mode 100755 index 00000000..be881fed --- /dev/null +++ b/tests/http-rtltcp-test.sh @@ -0,0 +1,141 @@ +#!/bin/sh +# +# End-to-end read-back test of the rtl_433 HTTP/WS API over the *live* input +# path. +# +# http-integration-test.sh can't do this: it serves an idle server (no decodes). +# The one-shot -y/-r file path can't either -- it exit()s as soon as the input +# is consumed (before the mg_mgr_poll loop), so no client could connect in time. +# Only the live SDR/poll loop keeps the server up while a decode flows, which is +# what the fake rtl_tcp source below gives us. +# +# Here a fake rtl_tcp server (tests/rtl_tcp_serve.py) streams a synthetic OOK +# signal, so rtl_433 runs through its live SDR/poll loop and the HTTP server +# stays up. We feed a known Nice Flor-s vector, wait for the decode, then connect +# a WebSocket client and assert the event comes back in the server's history +# replay -- a genuine event -> history -> broadcast -> wire round-trip. +# +# Usage: http-rtltcp-test.sh [path-to-rtl_433-binary] + +set -u + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +RTL_433=${1:-"$SCRIPT_DIR/../src/rtl_433"} +HOST=127.0.0.1 + +if [ ! -x "$RTL_433" ]; then + echo "ERROR: rtl_433 binary not found or not executable: $RTL_433" >&2 + exit 99 +fi + +# A documented Nice Flor-s test vector (see src/devices/nice_flor_s.c). The +# bitstring is 0xe7a760b94372e (52 bits) as the pulse slicer sees it; +# rtl_tcp_serve.py renders short=1 / long=0 OOK pulses. +DEVICE_NAME="Nice Flor-s remote control" +BITS="1110011110100111011000001011100101000011011100101110" +EXPECT_MODEL="Nice-FlorS" + +PROTO=$("$RTL_433" -R help 2>&1 \ + | grep "$DEVICE_NAME" \ + | grep -oE '\[[0-9]+\]' | head -1 | tr -d '[]') +if [ -z "$PROTO" ]; then + echo "ERROR: could not find protocol number for '$DEVICE_NAME'" >&2 + exit 98 +fi +echo "Resolved '$DEVICE_NAME' to protocol -R $PROTO" + +# Pick two free ports (HTTP API + fake rtl_tcp) so parallel ctest runs don't collide. +pick_port() { + python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()' 2>/dev/null +} +HTTPPORT=${HTTPPORT:-$(pick_port)} +TCPPORT=${TCPPORT:-$(pick_port)} +HTTPPORT=${HTTPPORT:-18433} +TCPPORT=${TCPPORT:-18533} +BASE="http://$HOST:$HTTPPORT" + +SERVER_LOG=$(mktemp 2>/dev/null || echo /tmp/rtl_433_rtltcp_test.$$.log) +TCP_LOG=$(mktemp 2>/dev/null || echo /tmp/rtl_433_rtltcp_srv.$$.log) +RTL_PID="" +TCP_PID="" + +cleanup() { + if [ -n "$RTL_PID" ] && kill -0 "$RTL_PID" 2>/dev/null; then + # SIGTERM -> clean shutdown -> atexit/gcov flush. Do NOT use SIGKILL. + kill -TERM "$RTL_PID" 2>/dev/null + for _ in 1 2 3 4 5 6 7 8 9 10; do + kill -0 "$RTL_PID" 2>/dev/null || break + sleep 0.3 + done + kill -0 "$RTL_PID" 2>/dev/null && kill -KILL "$RTL_PID" 2>/dev/null + wait "$RTL_PID" 2>/dev/null + fi + if [ -n "$TCP_PID" ] && kill -0 "$TCP_PID" 2>/dev/null; then + kill -TERM "$TCP_PID" 2>/dev/null + wait "$TCP_PID" 2>/dev/null + fi + rm -f "$SERVER_LOG" "$TCP_LOG" +} +trap cleanup EXIT INT TERM + +# --- start the fake rtl_tcp server and wait until it is listening --- +echo "Starting fake rtl_tcp server on $HOST:$TCPPORT" +python3 "$SCRIPT_DIR/rtl_tcp_serve.py" --port "$TCPPORT" --bits "$BITS" \ + >"$TCP_LOG" 2>&1 & +TCP_PID=$! +listening=0 +for _ in $(seq 1 50); do + if ! kill -0 "$TCP_PID" 2>/dev/null; then + echo "ERROR: rtl_tcp server exited during startup" >&2 + cat "$TCP_LOG" >&2 + exit 96 + fi + grep -q "LISTENING" "$TCP_LOG" 2>/dev/null && { listening=1; break; } + sleep 0.1 +done +if [ "$listening" != 1 ]; then + echo "ERROR: rtl_tcp server did not start listening" >&2 + cat "$TCP_LOG" >&2 + exit 96 +fi + +# --- start rtl_433 against it, with HTTP API + json so we can observe the decode --- +echo "Starting rtl_433: -d rtl_tcp:$HOST:$TCPPORT -R $PROTO -F $BASE" +"$RTL_433" -d "rtl_tcp:$HOST:$TCPPORT" -R "$PROTO" \ + -F "http://$HOST:$HTTPPORT" -F json >"$SERVER_LOG" 2>&1 & +RTL_PID=$! + +# Wait for the HTTP server to come up *and* for the decode to land (the signal +# plays ~0.5 s into the stream). Polling the json output makes the WS read-back +# deterministic: once the event is decoded it is in the history ring for replay. +ready=0 +for _ in $(seq 1 100); do + if ! kill -0 "$RTL_PID" 2>/dev/null; then + echo "ERROR: rtl_433 exited during startup" >&2 + cat "$SERVER_LOG" >&2 + exit 95 + fi + if grep -q "$EXPECT_MODEL" "$SERVER_LOG" 2>/dev/null \ + && curl -s -o /dev/null --max-time 1 "$BASE/"; then + ready=1 + break + fi + sleep 0.2 +done +if [ "$ready" != 1 ]; then + echo "ERROR: never saw a '$EXPECT_MODEL' decode with the HTTP server up" >&2 + cat "$SERVER_LOG" >&2 + exit 94 +fi +echo "Decode observed and HTTP server ready on $BASE" + +# --- the actual assertion: the decode is retrievable over the WS API --- +echo "--- ws read-back ---" +if python3 "$SCRIPT_DIR/ws-probe.py" "$HOST" "$HTTPPORT" 10 "$EXPECT_MODEL"; then + echo "ok: '$EXPECT_MODEL' decode round-tripped through the HTTP/WS API" + exit 0 +else + echo "NOT OK: decode did not come back over the WS API" >&2 + cat "$SERVER_LOG" >&2 + exit 1 +fi diff --git a/tests/rtl_tcp_serve.py b/tests/rtl_tcp_serve.py new file mode 100755 index 00000000..bb46960a --- /dev/null +++ b/tests/rtl_tcp_serve.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Minimal fake rtl_tcp server for black-box testing rtl_433's live input path. + +Speaks just enough of the rtl_tcp protocol to feed rtl_433 a synthetic OOK +signal over TCP. This drives rtl_433 through its live SDR/poll loop -- which +keeps the HTTP/WS server responsive -- instead of the one-shot `-r`/`-y` file +path that exit()s as soon as the input is consumed (rtl_433.c, the test-data +block that ends in exit(0), runs *before* the mg_mgr_poll loop). That makes it +possible to feed a decode and then read it back over the HTTP API while the +server is still up. + +rtl_tcp wire protocol (only the parts rtl_433 needs): + server -> client: 12-byte header b"RTL0" + uint32be tuner_type + uint32be gain_count + client -> server: 5-byte commands (1 byte cmd + uint32be param) -- drained and ignored + server -> client: raw CU8 IQ sample stream (interleaved unsigned 8-bit I,Q) + +The signal is synthesized as OOK_PULSE_PWM to match the rtl_433 pulse slicer: +short pulse = bit 1, long pulse = bit 0, an optional trailing sync pulse to +close the row, framed by a lead-in gap (long enough to settle the detector's +noise estimate) and a final reset gap. After the burst it streams silence so the +TCP connection -- and therefore rtl_433's server -- stays up until the client +goes away or we are signalled. + +Pure standard library so it runs in CI without extra packages. + +Usage: rtl_tcp_serve.py --port N --bits BITSTRING [options] + --port N TCP port to listen on (required; use the same one in -d rtl_tcp:...) + --bits S data bits as a string of '0'/'1' (short=1, long=0) + --rate HZ sample rate the signal is generated for (default 250000) + --short US short pulse width (default 500) + --long US long pulse width (default 1000) + --sync US trailing sync pulse width, 0 to omit (default 1500) + --gap US inter-pulse gap (default 500) + --lead-in US leading silence (default 8000; must exceed ~1024 samples) + --reset US trailing silence (default 6000; must exceed the reset_limit) +Prints "LISTENING " to stdout once bound, so a caller can synchronize. +""" +import argparse +import math +import socket +import struct +import sys +import threading + + +def synth_cu8(bits, rate, short_us, long_us, sync_us, gap_us, lead_in_us, reset_us): + """Return CU8 bytes for an OOK_PULSE_PWM burst (short=1, long=0).""" + samples_per_us = rate / 1e6 + tone_hz = 50000.0 # IF offset so "on" is a real tone, not pure DC + buf = bytearray() + phase = [0.0] + + def emit(us, on): + for _ in range(int(round(us * samples_per_us))): + if on: + i = 128 + int(100 * math.cos(phase[0])) + q = 128 + int(100 * math.sin(phase[0])) + phase[0] += 2 * math.pi * tone_hz / rate + else: + i = q = 128 + buf.append(max(0, min(255, i))) + buf.append(max(0, min(255, q))) + + emit(lead_in_us, False) + for b in bits: + emit(short_us if b == "1" else long_us, True) + emit(gap_us, False) + if sync_us > 0: + emit(sync_us, True) # closes the data row -> trailing empty row + emit(reset_us, False) + return bytes(buf) + + +def drain_commands(conn): + """Read and discard the 5-byte rtl_tcp commands rtl_433 sends (set freq, + rate, gain, ...). We honor none of them; the signal is pre-synthesized.""" + try: + while True: + if not conn.recv(4096): + return + except OSError: + return + + +def serve(port, payload): + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(("127.0.0.1", port)) + srv.listen(1) + bound = srv.getsockname()[1] + print("LISTENING %d" % bound, flush=True) + + conn, _ = srv.accept() + srv.close() + try: + # rtl_tcp dongle-info header: magic + tuner type (5 = R820T) + gain count. + conn.sendall(b"RTL0" + struct.pack(">II", 5, 0)) + threading.Thread(target=drain_commands, args=(conn,), daemon=True).start() + + conn.sendall(payload) + # Keep the stream (and thus rtl_433's server) alive with silence until + # the client disconnects or we're killed. + silence = bytes([128]) * 16384 + while True: + conn.sendall(silence) + except OSError: + pass # client went away -- normal shutdown + finally: + try: + conn.close() + except OSError: + pass + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--port", type=int, required=True) + p.add_argument("--bits", required=True) + p.add_argument("--rate", type=int, default=250000) + p.add_argument("--short", type=float, default=500) + p.add_argument("--long", type=float, default=1000) + p.add_argument("--sync", type=float, default=1500) + p.add_argument("--gap", type=float, default=500) + p.add_argument("--lead-in", type=float, default=8000) + p.add_argument("--reset", type=float, default=6000) + a = p.parse_args() + if any(c not in "01" for c in a.bits): + print("error: --bits must be a string of 0/1", file=sys.stderr) + return 2 + payload = synth_cu8(a.bits, a.rate, a.short, a.long, a.sync, + a.gap, getattr(a, "lead_in"), a.reset) + serve(a.port, payload) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/ws-probe.py b/tests/ws-probe.py new file mode 100755 index 00000000..d74cca62 --- /dev/null +++ b/tests/ws-probe.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Minimal WebSocket probe for the rtl_433 HTTP/WS API. + +Connects to a running rtl_433 HTTP server (started elsewhere, e.g. with +`-D manual -F http://host:port`), completes the WebSocket handshake, reads the +meta frame the server pushes on connect, then sends a JSON-RPC command frame +and reads the reply. This exercises the server's WebSocket code paths +(handshake -> meta/history push -> handle_ws_rpc -> rpc_response_ws). + +Pure standard library so it runs in CI without extra packages. + +Usage: ws-probe.py HOST PORT [timeout_seconds] [expect_substring] +If expect_substring is given, the probe also asserts that a pushed frame (the +history the server replays on connect, or a live broadcast) contains it -- used +to confirm a decoded event is retrievable over the WS API. +Exit 0 on success, non-zero otherwise; received text frames go to stdout. +""" +import base64 +import json +import os +import socket +import sys + +OP_TEXT = 0x1 +OP_CLOSE = 0x8 +OP_PING = 0x9 + + +def recv_exact(sock, n): + buf = b"" + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise EOFError("connection closed by server") + buf += chunk + return buf + + +def read_frame(sock): + """Read one WebSocket frame, return (opcode, payload_bytes).""" + b0, b1 = recv_exact(sock, 2) + opcode = b0 & 0x0F + masked = b1 & 0x80 + length = b1 & 0x7F + if length == 126: + length = int.from_bytes(recv_exact(sock, 2), "big") + elif length == 127: + length = int.from_bytes(recv_exact(sock, 8), "big") + mask = recv_exact(sock, 4) if masked else b"" + payload = recv_exact(sock, length) if length else b"" + if masked: + payload = bytes(p ^ mask[i % 4] for i, p in enumerate(payload)) + return opcode, payload + + +def send_text(sock, text): + """Send a masked text frame (clients MUST mask, per RFC 6455).""" + data = text.encode("utf-8") + n = len(data) + header = bytes([0x80 | OP_TEXT]) # FIN + text + if n < 126: + header += bytes([0x80 | n]) + elif n < 65536: + header += bytes([0x80 | 126]) + n.to_bytes(2, "big") + else: + header += bytes([0x80 | 127]) + n.to_bytes(8, "big") + mask = os.urandom(4) + masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data)) + sock.sendall(header + mask + masked) + + +def handshake(sock, host, port): + key = base64.b64encode(os.urandom(16)).decode() + req = ( + "GET / HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n" + ) + sock.sendall(req.encode()) + resp = b"" + while b"\r\n\r\n" not in resp: + resp += recv_exact(sock, 1) + status = resp.split(b"\r\n", 1)[0].decode(errors="replace") + if "101" not in status: + raise RuntimeError(f"handshake failed: {status!r}") + + +def read_text_frame(sock): + """Read frames until a text frame arrives, answering pings, ignoring close.""" + while True: + opcode, payload = read_frame(sock) + if opcode == OP_TEXT: + return payload.decode("utf-8", errors="replace") + if opcode == OP_PING: + continue # server ping; ignore for this short-lived probe + if opcode == OP_CLOSE: + raise EOFError("server sent close frame") + + +def read_until_contains(sock, needle, max_frames=20): + """Read text frames until one contains needle. The server also broadcasts + log messages over the same socket, so skip frames that aren't the one we + want. Returns the matching frame or None if max_frames is exhausted.""" + for _ in range(max_frames): + frame = read_text_frame(sock) + print(" <<", frame) + if needle in frame: + return frame + return None + + +def main(): + if len(sys.argv) < 3: + print("usage: ws-probe.py HOST PORT [timeout]", file=sys.stderr) + return 2 + host = sys.argv[1] + port = int(sys.argv[2]) + timeout = float(sys.argv[3]) if len(sys.argv) > 3 else 10.0 + expect = sys.argv[4] if len(sys.argv) > 4 else None + + sock = socket.create_connection((host, port), timeout=timeout) + sock.settimeout(timeout) + try: + handshake(sock, host, port) + + # On connect the server pushes the meta frame (and history, empty here). + if read_until_contains(sock, "center_frequency") is None: + print("FAIL: never received a meta frame with 'center_frequency'", file=sys.stderr) + return 1 + + # If asked, confirm a decoded event is retrievable over the WS API: the + # server replays its history ring on connect, so a decode that already + # happened arrives as a pushed frame (a live broadcast also matches). + if expect is not None: + if read_until_contains(sock, expect, max_frames=200) is None: + print("FAIL: never received a frame containing %r" % expect, file=sys.stderr) + return 1 + + # The WebSocket RPC uses the simple {"cmd": ...} form (json_parse), + # NOT the JSON-RPC envelope the /jsonrpc HTTP endpoint expects. + send_text(sock, json.dumps({"cmd": "get_sample_rate"})) + reply = read_until_contains(sock, "\"result\"") + if reply is None: + print("FAIL: no rpc reply containing 'result'", file=sys.stderr) + return 1 + + print("OK: websocket handshake, meta push, and rpc round-trip") + return 0 + finally: + try: + sock.close() + except OSError: + pass + + +if __name__ == "__main__": + sys.exit(main())