YardStick One RF toolkit for scanning, capturing, analyzing, and transmitting sub-GHz signals. Ships with an interactive TUI and a web UI.
- Scan ISM bands (315 / 390 / 433 / 868 / 915 MHz) with RSSI threshold detection
- Capture packets with preset or custom modulation configs (ASK, 2-FSK, GFSK, MSK)
- Analyze captures: entropy, preamble detection, byte histograms, Hamming distance, fixed-vs-rolling classification
- Visualize in multiple formats: bits, hex, hex+ASCII, OOK run-lengths, Manchester decoding
- Edit captures: delete packets, filter noise by entropy, save as new files, rename
- Transmit hex payloads or replay captured packets (single or sequence)
- Spectrum plot — live SVG chart on the web UI Scan tab
- Two UIs: rich-based TUI for terminal-only workflows, Flask-based web UI for everything else
- Python 3.9 or later
- YardStick One dongle
- libusb 1.0 (install via system package manager)
- rfcat (installed automatically by bootstrap or via
pip install -e .[rfcat])
git clone <this-repo>
cd ys1-toolkit
python3 ys1_scanner.pyFirst run triggers the bootstrap: creates a venv at ./.venv/, installs rfcat from GitHub plus rich and flask, re-execs into the venv automatically. This is the path to use if you just want the tool to work without thinking about packaging.
# On Debian/Ubuntu/Kali:
sudo apt install libusb-1.0-0-dev python3-venv python3-pip
# On macOS:
brew install libusb
# Then:
python3 -m venv .venv
source .venv/bin/activate
pip install -e .[rfcat]This registers the ys1-scanner command on your PATH, so you can invoke it from anywhere. All code edits take effect immediately — no reinstall needed.
sudo ys1-scanner
# or, without installing:
sudo python3 ys1_scanner.pysudo ys1-scanner --webBrowser opens at http://127.0.0.1:8765 automatically. Tabs for Scan / Capture / Analyze / Transmit. Live SSE updates during scan and capture.
Over SSH with port forwarding:
ssh -L 8765:127.0.0.1:8765 user@host
# then on the remote:
sudo ys1-scanner --web --no-browser
# locally, open http://127.0.0.1:8765# Scan only
sudo ys1-scanner --scan-only --threshold -90
# Capture on a specific frequency
sudo ys1-scanner --freq 318000000 --duration 30 --preset entry-remote
# Custom modulation (overrides preset)
sudo ys1-scanner --freq 318000000 --modulation ASK --drate 1200 --bw 58000
# Analyze a saved capture
ys1-scanner --analyze captures/my_capture.jsonl
# Transmit
sudo ys1-scanner --freq 318000000 --tx a5c3b2d1 --tx-count 5Run ys1-scanner --help for the full flag list.
ys1-toolkit/
├── ys1_scanner.py # Thin entry point (bootstraps deps, calls ys1.cli.main)
├── pyproject.toml
├── README.md
├── ys1/
│ ├── __init__.py
│ ├── bootstrap.py # Dependency install / venv re-exec
│ ├── constants.py # Presets, bands, modulations, thresholds
│ ├── scanner.py # Scanner class (wraps rfcat)
│ ├── analyzer.py # PacketAnalyzer class
│ ├── tui.py # Interactive TUI (rich)
│ ├── cli.py # argparse + dispatch
│ └── web/
│ ├── app.py # WebApp (Flask orchestrator)
│ ├── routes/
│ │ ├── scan.py # /api/scan*
│ │ ├── capture.py # /api/capture*
│ │ ├── analyze.py # /api/captures*
│ │ └── transmit.py # /api/transmit
│ └── static/
│ ├── index.html
│ ├── styles.css
│ └── app.js
└── tests/
├── conftest.py # rflib stubs, fixtures
├── test_imports.py # AST-based "no undefined names"
├── test_analyzer.py
├── test_scanner.py
├── test_tui_helpers.py
├── test_web_routes.py
└── test_bootstrap.py
- Always run with
sudofor USB access to the YardStick One (unless you've configured udev rules to give your user access to/dev/ttyACM*). - Captures are saved as JSONL (one packet per line, crash-safe) plus a JSON summary on successful completion. Both go into
captures/by default. - The web UI binds to
127.0.0.1only. It's not accessible from the network. Use SSH port-forwarding if you need remote access. sudo+ bootstrap + venv interacts awkwardly: if yousudo python3 ys1_scanner.pyon a PEP-668 system, the bootstrap creates the venv as root and re-execs. Subsequent runs work but the venv is root-owned. Simpler topython3 ys1_scanner.pyonce (unprivileged, to create the venv), thensudo ./.venv/bin/python ys1_scanner.pyfrom then on.
This tool transmits RF on ISM bands. You are responsible for ensuring any transmission complies with local regulations. Replay attacks against RF-controlled access systems (cars, gates, doors) that you don't own are illegal in most jurisdictions. Use it on your own devices.
The project has a pytest suite covering pure-logic components and the web routes. Tests don't require a YardStick One or any USB hardware — conftest.py stubs rflib so tests run anywhere.
pip install -e .[dev]
pytest # all 131 tests, ~1 second
pytest tests/test_imports.py # just the "no undefined names" AST check
pytest -v # verbose (lists every test)test_imports.py— every module imports cleanly, AND every function body's Name references resolve. This catches refactor regressions where an import gets dropped but the reference is inside a method (so plainimport foodoesn't fail). This test would have caught the missingfrom rich.align import Alignthat came up after the monolith-to-package split.test_analyzer.py—PacketAnalyzerpure functions: Shannon entropy, preamble detection, Hamming distance, byte histograms, common prefix, end-to-endanalyze()contract.test_scanner.py—Scannerstatic methods: the noise filter (locked in after two separate bug-fixes), JSON/JSONL capture file loading, RSSI-to-dBm conversion.test_tui_helpers.py—_parse_multiselect(select packets to transmit: "1,3-5,all"),_trim_zeros,_bytes_to_bitstring.test_web_routes.py— Flask test client against every route: path-traversal rejection, 404/409 cases, sibling-file (.jsonl + .json) handling on rename/delete.test_bootstrap.py— PEP 668 detection, Windows vs POSIX venv paths.
Python imports are lazy about function bodies. import foo doesn't check whether foo.bar() references a name that exists — it just loads the module. The NameError only fires when bar() actually runs. This bites hard during refactors: extract a class into a new module, forget one of its import dependencies, tests pass, CI passes, sudo python ys1_scanner.py passes the menu but crashes the moment you hit the scan button.
The AST test walks every function body and checks that every Name reference in Load context resolves to a builtin, module-level define, enclosing function's local, comprehension target, or its own local. If you delete from rich.align import Align from tui.py, the test fails at tui.py:263 undefined name 'Align' in TuiApp._show_header() — pointing at the exact file, line, and method. Costs milliseconds; catches a class of bug that would otherwise only show up in the user's terminal.