Victoria's Code STA / 01
SDR — build log

Hardware first, AI later

Hardware first, AI later

Building a device-agnostic validation framework for autonomous systems. Starting where it matters: can we talk to a drone and listen to the spectrum?

Phase 0 & 1 RTL-SDR DJI Tello Python Weekend project

The idea, in one paragraph

I want to build a framework that can validate autonomous systems — drones, ground vehicles, surface vehicles, whatever. Command the device, inject environmental conditions, evaluate the response. Device-agnostic: swap the adapter, same test runs. Eventually it'll generate scenarios from requirements, produce certification evidence, handle DO-178C traceability. But none of that matters if I can't talk to the hardware first. So that's where we start.

Ground rule for this project No AI, no platform, no orchestrator — not yet. Phase 0 is plugging things in and seeing if they respond. Phase 1 is writing code that commands a drone and receives signals on an SDR. Two separate tracks, running side by side. They don't need to know about each other yet.

Phase 0 — Hardware reality check

Three devices to verify. This should take about 15 minutes if nothing is broken, or an evening if drivers are involved.

RTL-SDR

Software-defined radio receiver. Receive-only — can listen across ~24 MHz to 1.7 GHz, cannot transmit.

Verify model

DJI Tello

WiFi-controlled mini drone. Simple UDP command protocol. Python SDK available (djitellopy).

Verify connectivity

DJI Mavic

Consumer drone. Most models lack an SDK. Need to identify exact model to know what's available.

Identify model

1RTL-SDR — identify and test

Plug in the dongle and find out what chipset and tuner you have. Every RTL-SDR uses an RTL2832U demodulator, but the tuner chip determines frequency range and quality.

Install the tools:

# Linux (Debian/Ubuntu)
sudo apt install rtl-sdr

# macOS
brew install librtlsdr

# Windows: download from osmocom.org/projects/rtl-sdr
# Install Zadig driver first (WinUSB for Bulk-In Interface 0)

Run the test:

rtl_test -t

What to look for in the output:

# Example output:
Found 1 device(s):
  0:  Realtek, RTL2838UHIDIR, SN: 00000001

Using device 0: Generic RTL2832U OEM
Found Rafael Micro R820T tuner    # <-- this is the key line
What the tuner chip tells you R820T/R820T2 — most common, 24–1766 MHz range, solid general-purpose. E4000 — wider range (52–2200 MHz with a gap), harder to find now. FC0012/FC0013 — older, limited range. Still usable. R860 — newer revision of R820T, better noise performance. Note the exact tuner — it determines what frequencies we can receive in later phases.

Quick functional test — receive FM radio:

# Tune to a local FM station (e.g., 96.3 MHz = 96.3e6 Hz)
rtl_fm -f 96.3e6 -M wbfm -s 200000 -r 48000 - | aplay -r 48000 -f S16_LE

# macOS: pipe to `play` (from sox) instead of aplay
rtl_fm -f 96.3e6 -M wbfm -s 200000 -r 48000 - | play -r 48000 -t raw -e signed -b 16 -c 1 -

If you hear a radio station, the SDR works. That's all we need for Phase 0. We're not doing anything fancy with it yet — just confirming the hardware is alive and the drivers are installed.

2Tello — connect and query

The Tello speaks a simple UDP protocol on port 8889. Power it on, join its WiFi network (TELLO-XXXXXX), and run:

# Install the SDK
pip install djitellopy
# tello_check.py
from djitellopy import Tello

drone = Tello()
drone.connect()

print(f"Battery:     {drone.get_battery()}%")
print(f"Temperature: {drone.get_temperature()}°C")
print(f"Barometer:   {drone.get_barometer()} cm")
print(f"TOF:         {drone.get_distance_tof()} cm")
print(f"WiFi SNR:    {drone.query_wifi_snr()} dB")
print(f"SDK version: {drone.query_sdk_version()}")

drone.end()

Expected output: six lines of telemetry data. If you get them, the Tello is ready.

Common gotcha The Tello creates its own WiFi access point. When you connect to it, your laptop loses internet. If you need internet simultaneously (to install packages, look things up), either install everything first, use a phone hotspot as a secondary connection, or use a USB WiFi adapter for the Tello while your main adapter stays on your home network.

3Mavic — identify the model

Check the label on the drone or open DJI Fly and look at the device name. This matters because most consumer Mavics have no programmable SDK:

No SDK (consumer line):
  Mavic Mini, Mini 2, Mini 3/3 Pro
  Mavic Air, Air 2, Air 2S
  Mavic 3 (standard/Cine)

SDK available (enterprise/developer):
  Mavic 2 Enterprise (Advanced/Dual)
  Mavic 3 Enterprise/Thermal
  Matrice 30/300/350 series

If it's a consumer model: note it and move on. It won't be a Phase 1 adapter, but it's useful context for later. The Tello is the primary development drone. If it's an Enterprise model: bonus — DJI Mobile SDK or MSDK 5 applies, and we'll build an adapter for it later.


Phase 1 — Two tracks, side by side

Once hardware is confirmed, we build two independent scripts. They don't integrate with each other yet. The goal is clean, working code for each device that we'll later wrap in a common interface.

Project layout

validation-framework/
├── sdr/
│   ├── receive.py # RTL-SDR receiver script
│   ├── spectrum.py # Power spectrum scanner
│   └── adsb.py # ADS-B aircraft decoder (optional)
├── drone/
│   ├── tello_fly.py # Tello flight + telemetry script
│   └── tello_telemetry.py # Telemetry-only (no flight)
├── logs/ # Telemetry and signal logs
├── requirements.txt
└── README.md

Track A: RTL-SDR — receive and log

The "hello world" for SDR is receiving FM radio. But that's analog audio — useful for confirming the hardware, not useful for a validation framework. The first real exercise is a power spectrum scan: sweep a frequency range and log the signal power at each step. This is the primitive that everything else builds on — anomaly detection, interference monitoring, spectrum characterization.

# sdr/spectrum.py
# Sweep a frequency range and log power at each step
# Requires: pip install pyrtlsdr numpy

import numpy as np
from rtlsdr import RtlSdr
import json
from datetime import datetime

def scan_spectrum(center_freq: float, bandwidth: float, num_samples: int = 1024):
    """Capture power spectrum at a given center frequency."""
    sdr = RtlSdr()
    sdr.sample_rate = 2.048e6      # 2.048 MHz
    sdr.center_freq = center_freq   # Hz
    sdr.gain = 'auto'

    samples = sdr.read_samples(num_samples)
    sdr.close()

    # Compute power spectral density
    fft = np.fft.fftshift(np.fft.fft(samples))
    power_db = 20 * np.log10(np.abs(fft) + 1e-10)
    freqs = np.fft.fftshift(
        np.fft.fftfreq(len(samples), 1.0 / sdr.sample_rate)
    ) + center_freq

    return {
        "timestamp": datetime.utcnow().isoformat(),
        "center_freq_hz": center_freq,
        "sample_rate": sdr.sample_rate,
        "num_samples": num_samples,
        "peak_power_db": float(np.max(power_db)),
        "mean_power_db": float(np.mean(power_db)),
        "freq_at_peak_hz": float(freqs[np.argmax(power_db)]),
    }


if __name__ == "__main__":
    # Scan a few interesting bands
    bands = {
        "FM broadcast":  96.3e6,
        "ADS-B (1090)":  1090e6,
        "GPS L1":         1575.42e6,
        "ISM 433 MHz":   433.92e6,
        "ISM 915 MHz":   915e6,
    }

    results = []
    for name, freq in bands.items():
        try:
            result = scan_spectrum(freq)
            result["band_name"] = name
            results.append(result)
            print(f"{name:16} peak: {result['peak_power_db']:6.1f} dB  mean: {result['mean_power_db']:6.1f} dB")
        except Exception as e:
            print(f"{name}: skipped ({e})")

    # Save to log
    with open("logs/spectrum_scan.jsonl", "a") as f:
        for r in results:
            f.write(json.dumps(r) + "\n")
Why JSONL? One JSON object per line, append-only. Same format that event stores use. When we eventually wire this into a framework, the logs are already in the right shape. No reformatting, no migration. A small decision now that saves time later.

Track B: Tello — fly a mission and log telemetry

The first flight script does three things: take off, fly a simple pattern, land. While flying, it logs telemetry (battery, height, temperature, barometer) to JSONL at 1-second intervals. The telemetry logging runs in a background thread so it doesn't block the flight commands.

# drone/tello_fly.py
# First flight: takeoff, move, land. Log telemetry throughout.
# Requires: pip install djitellopy

import json
import time
import threading
from datetime import datetime
from djitellopy import Tello


def telemetry_logger(drone: Tello, stop_event: threading.Event):
    """Background thread: log telemetry every second."""
    with open("logs/tello_telemetry.jsonl", "a") as f:
        while not stop_event.is_set():
            entry = {
                "timestamp":     datetime.utcnow().isoformat(),
                "battery_pct":   drone.get_battery(),
                "height_cm":     drone.get_height(),
                "temperature_c": drone.get_temperature(),
                "barometer_cm":  drone.get_barometer(),
                "tof_cm":        drone.get_distance_tof(),
                "flight_time_s": drone.get_flight_time(),
            }
            f.write(json.dumps(entry) + "\n")
            print(f"  alt={entry['height_cm']}cm  bat={entry['battery_pct']}%  temp={entry['temperature_c']}°C")
            time.sleep(1)


def main():
    drone = Tello()
    drone.connect()
    print(f"Connected. Battery: {drone.get_battery()}%")

    # Start telemetry logging
    stop = threading.Event()
    logger = threading.Thread(target=telemetry_logger, args=(drone, stop))
    logger.start()

    try:
        print("Taking off...")
        drone.takeoff()
        time.sleep(3)

        print("Moving forward 50cm...")
        drone.move_forward(50)
        time.sleep(2)

        print("Rotating 90°...")
        drone.rotate_clockwise(90)
        time.sleep(2)

        print("Moving forward 50cm...")
        drone.move_forward(50)
        time.sleep(2)

        print("Landing...")
        drone.land()
        time.sleep(3)

    except Exception as e:
        print(f"Error: {e}")
        drone.land()

    finally:
        stop.set()
        logger.join()
        drone.end()
        print("Done. Telemetry saved to logs/tello_telemetry.jsonl")


if __name__ == "__main__":
    main()
Fly safely Clear the area. The Tello is small but the props can cut skin. First flight should be indoors in an open room with nothing breakable. Keep your hand near the keyboard — Ctrl+C triggers the finally block which calls land(). Check battery before every flight; below 20% the Tello auto-lands without warning.

The telemetry-only option

If you want to develop without flying (testing parsing, logging, interfaces), this script connects to the Tello and reads telemetry without taking off:

# drone/tello_telemetry.py
# Read telemetry from Tello without flying. Good for dev/test.

from djitellopy import Tello
import json
import time
from datetime import datetime

drone = Tello()
drone.connect()
print(f"Connected. Battery: {drone.get_battery()}%\n")

with open("logs/tello_ground.jsonl", "a") as f:
    for i in range(10):
        state = {
            "timestamp":     datetime.utcnow().isoformat(),
            "battery_pct":   drone.get_battery(),
            "temperature_c": drone.get_temperature(),
            "barometer_cm":  drone.get_barometer(),
            "tof_cm":        drone.get_distance_tof(),
            "wifi_snr":      drone.query_wifi_snr(),
        }
        print(json.dumps(state, indent=2))
        f.write(json.dumps(state) + "\n")
        time.sleep(1)

drone.end()
print("\nSaved to logs/tello_ground.jsonl")

Exit criteria

Phase 0 and Phase 1 are done when all of this is true:

  • RTL-SDR model and tuner chip identified and recorded
  • RTL-SDR receives FM radio or completes a spectrum scan
  • Tello connects over WiFi and returns telemetry
  • Tello completes a basic flight mission (takeoff, move, land)
  • Both devices produce JSONL log files in logs/
  • Mavic model identified; SDK availability noted

What comes next

Phase 2 wraps both devices behind a common interface. Not a platform, not an orchestrator — just a Python protocol that any device can implement. The same script that flies the Tello should be able to drive a PX4 simulator, or a ground rover, or anything else, by swapping one config line.

But that's the next post. Right now: plug things in, see if they work, and commit the logs.

Comments

Thoughts?

Comments are threads on GitHub, so a GitHub account is needed to post. No account? Email me instead — I read everything.