So you've purchased a fox from the Ham Radio Village during DEF CON .... now what?
The radio module on this board (a NiceRF SA868) is a real transmitter on the amateur (ham) bands (specifically VHF) — rated for ~2 W, and shipped here on its low-power setting (~25 dBm, roughly 0.3 W). In the US and most countries, transmitting there requires an amateur radio license — in the US, an entry-level Technician license covers it. Identifying with your own callsign is also required by law, which is exactly what the
callmessagesetting is for.The foxes come preprogrammed with a callsign just for the DEF CON 34 event so you can play along on location at the Las Vegas Convention Center with us. This applies to the Ham Radio Village fox hunt during posted contest hours from August 7–9, 2026 only.
Outside of this event, you are responsible for reprogramming the fox with either your own callsign or another event callsign.
Until you're licensed and have set your callsign, don't transmit into an antenna. You can still safely power the board and experiment if you transmit into a dummy load instead of an antenna (see Quick start).
This README is a full guide for what this piece of hardware is, what it does, how to put it on the air legally, how to change the callsign and message, and how to load your own song. We cover everything here.
- DEF CON 34 Decoy Fox
A fox is a small radio transmitter that beacons on a set schedule so that people carrying directional antennas and receivers can locate it. The sport of finding it goes by several names — fox hunting, transmitter hunting, radio direction finding (RDF), or the international name ARDF (Amateur Radio Direction Finding).
The idea is simple and old: a hidden transmitter identifies itself periodically, and hunters use the direction a signal comes from — plus how its strength changes as they move — to triangulate their way to it. It's part scavenger hunt, part orienteering, part practical radio skill (the same techniques are used to track down interference and stuck transmitters in the real world).
This fox does the classic thing: on each cycle it keys up the transmitter, sends its callsign in Morse code (so it's identifiable and legal), plays a short song (so it's fun and easy to recognize on the air), then goes quiet for a set delay before doing it again. One LED blink marks each cycle so you can see it's alive.
A typical hunt: several foxes are hidden around a park or venue, each on its own frequency or timing, and hunters race to find them all.
This piece of hardware is a tiny amateur-radio transmitter that plays a Morse-code ID and a short song on a loop. Built for fox hunting, it's the thing RF hunters are trying to find with their antennas.
The board supports both bands — but our default is VHF. The PCB design accommodates either band, and the antenna shipped with it is dual-band. But the SA868 radio module comes in band-specific versions, and one module is fitted per fox. Some of the newer designs are dual-band units, but versions previous to DC34 may be VHF only.
Our default build is VHF: the 2 m amateur band (144–148 MHz), and this README is written for that build. The DEF CON fox hunt runs 70 cm / UHF foxes too; if you have a UHF-populated board, everything here applies except the frequency — see Picking a frequency.
Your fox is built around two parts:
- Seeed Studio XIAO ESP32-C3 — a thumbnail-sized microcontroller board. This is the "brain" that runs the fox code. It has a USB-C port you'll use for flashing and loading code.
- NiceRF SA868 — a self-contained radio module. This is the "voice": it's
the actual transmitter that puts the Morse and song on the air. The
microcontroller talks to it over a serial (UART) connection. NiceRF sells
the SA868 in band-specific versions — a VHF part covering 134–174 MHz and
a UHF part covering 400–480 MHz — which are otherwise identical (same
footprint, same pins, same AT commands). The module is rated for ~2 W,
but
code.pydrives theHLpin low, which selects the module's low-power output (~25 dBm, roughly 0.3 W) — plenty for a venue-sized hunt and easier on the battery. Worth knowing if you're sanity-checking signal strength against distance while hunting.
Plus an antenna (connect it before powering on — see the warnings below) and a power source (USB, or a LiPo battery for field deployment).
The original design and software are from the C0ldbru / rot13labs fox at https://github.com/c0ldbru/fox (GPL-3.0). See Credits & license for the full attribution chain.
The fox code drives the SA868 and a status LED through these XIAO pins. You don't need this to use the fox, but it's handy if you're inspecting or repairing the board.
| Function | XIAO pin (code) | GPIO | Notes |
|---|---|---|---|
| PTT (push-to-talk) | D3 |
GPIO05 | Low = transmit, High = receive |
| PD (power-down) | D4 |
GPIO06 | Held high = module awake |
| HL (high/low power) | D5 |
GPIO07 | Low = ~25 dBm (low power); this is how the fox ships |
| UART RX (from radio) | D7 |
GPIO20 | Serial in |
| UART TX (to radio) | D6 |
GPIO21 | Serial out |
| MIC / audio out | D1 |
GPIO03 | Tone/song audio into the module |
| Status LED | D10 |
GPIO10 | One blink per cycle = alive |
Many of these foxes run from a small LiPo (lithium-polymer) battery so they can be hidden away from a USB outlet. LiPos pack a lot of energy into a tiny package, which is exactly why they're handy — and exactly why they deserve respect. A little care keeps them safe and long-lived.
Charging. The XIAO ESP32-C3 has a built-in charger: plug the board in over USB-C and it charges the connected LiPo automatically. A charge LED on the board indicates charging in progress. Charge on a hard, non-flammable surface (not on a bed, couch, or pile of papers), and don't leave a charging battery unattended for long stretches — this is the single most common-sense habit with any lithium cell.
Caution
⚠ This firmware has no battery protection — it never reads the pack and will transmit until the LiPo is flat (see Battery & deployment notes). Running a LiPo all the way down and leaving it there is the main way to damage one. Recharge the fox after use; if storing, disconnect the battery and look up safe storage charges based on the information on the battery (voltage, amp hours).
A few habits that matter:
- Watch the polarity. If you ever unplug and reconnect the battery, the
+and–must go back the way they came. Reversing a LiPo can destroy the board and damage the cell. If your battery connector isn't keyed, mark it. Our foxes have a JST connector that only allows for one polarity connection... but it's possible to force it and break the connector. - Never puncture, crush, or bend the cell. The soft foil pouch is not armor. A swollen, puffy, or hissing battery is done — stop using it and dispose of it properly (see below).
- Mind the heat. Don't leave the fox baking in a hot car or in direct sun for hours; heat is hard on lithium cells. If a battery gets hot to the touch while charging, unplug it.
- Keep the terminals from shorting. A loose battery in a bag with keys or coins can short across its leads. While we've used a JST connector, it's possible to short across the mounting on the fox PCB. Liquids will also cause this issue.
- Charge before you hide it, not after. Top it off, note your deploy time, and retrieve it promptly. These foxes aren't weather proof (enclosed designs can be found online or DIY).
Disposal. Don't put LiPos in household trash or curbside recycling — they can ignite when crushed. Most areas have battery-recycling drop-offs (many electronics and hardware stores host them). A swollen or damaged cell should be retired immediately and taken to one of these.
None of this is meant to scare you off — millions of devices use these cells safely every day. These warnings are meant as information for extended use and so there are no surprises as you use a new piece of hardware.
To charge indoors, on a bench, or anywhere you shouldn't be on the air:
- Plug in USB-C (charging starts on its own).
- Hold
B(BOOT) while plugging in the board, tapR(RESET), then releaseB.
No LED blink means it worked — the board is charging and will not transmit.
If that does not work with your board, try:
Hold B (BOOT) while powering on the board for 3-4 seconds without tapping the RESET.
This is the same move as Get into bootloader
mode, because it is that mode.
Holding B pulls GPIO9 low, and tapping R resets the chip so it samples that
pin — the ESP32-C3 comes up in its ROM serial-download mode, CircuitPython
never starts, code.py never runs, and nothing keys the radio. The XIAO's
battery charger is separate circuitry and runs either way.
Important
Use R — don't just plug in USB. The boot mode is sampled at reset. With
a battery attached the board is already running, so plugging in USB may not
reset it — and if it doesn't, the fox keeps transmitting while it charges.
Tapping R is what makes this reliable.
Two more things worth knowing:
- This is chip behavior, not a firmware feature. Nothing in
code.pyreads a button. It survives code edits and reflashes, but it is not a designed safety interlock — don't treat it as one. - It ends at the next reset. The boot mode is latched until the chip is
reset or powered down. Tapping
Ron its own, or cycling the battery, boots normally and the fox is transmitting within seconds — so leave the antenna or dummy load attached while charging.
For a fox that is unambiguously off, disconnect the battery.
In order to flash/program your fox, you'll need the following items:
- Thonny (or similar development environment). For more on how to install and use Thonny, see Point Thonny at the board.
For people who are already licensed and comfortable, and just want it on the air. Everyone else: skip to Step 1.
- Connect the antenna (or dummy load). Never key the SA868 without a load.
- Is the board already running CircuitPython? (A
>>>prompt with a CircuitPython banner in Thonny confirms it.) If not, flash it. - Open
code.py, set yourcallmessage(callsign) andfrequency. - In Thonny: File → Save As → CircuitPython device, save as exactly
code.pyat the device root. - Enter Ctrl+D in the shell to run. Healthy boot prints
initializing system ... ready, no callsign warning, then Morse + song on a loop.
Field edit rhythm: Ctrl+C to stop, edit, save, Ctrl+D to run.
Flashing is a one-time job that installs the CircuitPython language onto the board. You only need it if the board isn't already running CircuitPython — a factory-fresh board, or one in an unknown state, needs it; a board that's already been flashed skips straight to Step 2.
Quick check for a mystery board: plug it in normally (no button), open Thonny, and connect. A
>>>prompt with a CircuitPython banner means it's already flashed — go to Step 2.
Two things about this specific board that surprise people:
- No USB drive ever appears — before or after flashing. That's a property of this chip, not a fault. You confirm success by connecting in Thonny and seeing the CircuitPython prompt, not by looking for a drive.
- Thonny can't do the flash on this board (its installer expects a USB drive). Thonny handles Step 2 only. So Step 1 uses one of the two tools below.
- The fox board and a USB-C data cable (not charge-only — a charge-only cable is the #1 reason the board never shows up).
- The CircuitPython firmware
.binfile — download the latest stable.bin(not the.uf2) from https://circuitpython.org/board/seeed_xiao_esp32c3/. The filename looks likeadafruit-circuitpython-seeed_xiao_esp32c3-en_US-<VERSION>.bin. - The antenna or a dummy load connected — the fox transmits within seconds of its code running later, and keying the SA868 without a load can damage it.
To receive firmware, the board has to be listening for it:
- Unplug the board.
- Find the tiny button labeled
B(BOOT). - Press and hold
B, and while still holding, plug in the USB-C cable. - Hold
Bone more second, then release.
Nothing visible happens — that's expected. If a later step can't find the board, redoing this is almost always the fix.
Already plugged in? Hold
B, tap theR(RESET) button, then releaseB. Same result.
Now pick one of the two options below — they do exactly the same thing.
Best if you'd rather not install Python or touch a terminal, and you have Chrome or Edge plus an internet connection.
- In Chrome or Edge, open https://adafruit.github.io/Adafruit_WebSerial_ESPTool/.
- With the board in bootloader mode, click Connect (top right) and pick
your board's serial port. On Windows it looks like
COM5 (USB Serial Device)orUSB JTAG/serial debug unit. If you're not sure which entry it is: unplug, see which disappears, redo the full bootloader move (holdBwhile replugging), and pick the one that returns. - Click Erase and confirm. Wait for it to report done.
- Find the "Choose a file…" row with an offset box. Choose your
.bin, set the offset to0x0(that address matters), and click Program. A progress bar fills; success shows after a minute or two. - Disconnect the browser tool (click disconnect or close the tab). This matters — if the browser keeps holding the port, Thonny can't connect in Step 2 and it looks exactly like a failed flash.
- Unplug and replug normally (no button). It's now running CircuitPython.
Best if you already have Python (or don't mind installing it), want it to work offline, or expect to flash lots of boards.
- Install Python from https://www.python.org/downloads/. On Windows, check
"Add Python to PATH" during install. Then:
pip install esptool - Find your port: Windows — Device Manager → Ports (COM & LPT) → note
COMx. macOS —ls /dev/cu.*(e.g./dev/cu.usbmodem1101). Linux —ls /dev/ttyACM*(usually/dev/ttyACM0). - With the board in bootloader mode, erase the flash with (swap in your port):
Success ends with
python -m esptool --chip esp32c3 --port <PORT> erase-flashFlash memory erased successfully…and a reset. - The erase step will take the board out of bootloader mode, so redo the
B-button move, then write (swap in your port and real filename):Success showspython -m esptool --chip esp32c3 --port <PORT> write-flash -z 0x0 adafruit-circuitpython-seeed_xiao_esp32c3-en_US-<VERSION>.binHash of data verified.thenHard resetting…. - Unplug and replug normally (no button).
Recent esptool spells the commands with hyphens (
erase-flash/write-flash); older versions use underscores. Current versions accept both.If it hangs on
Connecting......, the board isn't in bootloader mode — redo theBmove. Ifpythonisn't recognized, trypy -m esptool.
Linux only: if the port shows "permission denied," run
sudo usermod -a -G dialout $USER, then log out and back in. If the port
appears then vanishes, a service called brltty may be grabbing it —
sudo apt remove brltty fixes that.
Flashing installed the language; now you load the fox program (code.py).
This step is repeatable — it's also how you change the callsign, frequency, or
song later.
- Install and open Thonny (https://thonny.org).
- Run → Configure interpreter… → interpreter CircuitPython (generic) → set Port → OK.
- Confirm the shell banner names CircuitPython (something like
Adafruit CircuitPython 10.x on … XIAO ESP32C3). Press Ctrl+D in the shell to reprint it if unsure.
A
>>>prompt alone is not proof. Thonny shows the identical prompt when it's connected to your computer's Python. Check the bottom-right corner of the window — it names the current interpreter. If it says "Local Python 3," or if Save As never offers a "CircuitPython device" destination, redo Configure interpreter. (Empty Port list? Something may still hold the port — close the browser flashing tab — or the board needs a plain replug.)
- Connect an antenna (or dummy load) — the fox goes on-air seconds after
code.pyruns. - Open this repo's
code.py. - File → Save As… → CircuitPython device → save as exactly
code.pyat the device root. That exact name is what CircuitPython auto-runs at boot. - Press Ctrl+D in the shell to run it. (While Thonny holds the
>>>prompt, the board waits idle — Ctrl+D soft-reboots into your code with the shell still attached, so you see its output.)
This is a single-file fox — just code.py, nothing else. A healthy first
boot prints:
initializing system ... ready
** NO BATTERY MONITORING ** deploy charged, retrieve promptly
…then the Morse and song transmit on a cycle, one LED blink each. If you see a callsign warning, you haven't set your callsign yet — see the next section.
Silence with no error usually means a misnamed file. View → Files shows the board's contents — the file must be at the root and spelled exactly
code.py.
Note: your fox arrives with the DEF CON event callsign already loaded, so it's ready to hunt out of the box. The
code.pyin this repo keeps the neutralN0CALLplaceholder shown below — so when you reflash or edit, you're setting your callsign, not transmitting someone else's. Once the event is over, put your own callsign here.
Open code.py. Near the top:
callmessage = "N0CALL DECOY DECOY N0CALL" # your callsign goes hereReplace this with your own callsign plus whatever short message you want. The fox sends this whole string in Morse each cycle. Examples:
callmessage = "W12ABCD FOX"
callmessage = "W12ABCD FOXHUNT DE W12ABCD"Note: If you have that many letters/numbers in your callsign, you've written something down wrong.
Rules and tips:
- Use your real, licensed callsign. Identifying is legally required, and
N0CALLis not a real callsign — it's a placeholder. - Leave
CALLSIGN_PLACEHOLDER = "N0CALL"alone. That line is a safety sentinel: ifN0CALLstill appears in yourcallmessage, the board prints a boot warning so you don't accidentally transmit an unset callsign. Setting your real callsign makes the warning go away — that's how you know it took. - Only characters in the Morse table are sent. Letters A–Z, digits 0–9, and
/are supported. Anything else (commas, dashes, etc.) is silently skipped. - Keep it reasonably short — the whole string is sent every cycle, so a long message means longer transmissions.
Save (code.py to the device) and Ctrl+D to apply. A clean boot with no
callsign warning confirms it.
frequency = 146.565 # MHz -- see the README ("Picking a frequency")(Yes, that comment points right here.)
The default, 146.565 MHz, is the customary "fox" frequency — commonly used for hidden-transmitter hunts and a good default when you're not at an event with assigned frequencies.
Two important cautions:
- Stay within your license privileges and any local band plan. Which frequencies you may use depends on your license class and your country/region. In the US, a Technician license covers the 2 m band this board uses.
- Keep it in-band for the module that's actually fitted. This applies for all hardware,
not just ours. Our default build is a 2 m (VHF) fox — stay in 144–148 MHz for those.
Some of our 2026 hardware is dual-band, which means it will transmit in both VHF and UHF
with the same module. If you do not have a dual-band module, setting a 70 cm frequency
on a VHF board won't produce a usable signal no matter what the antenna is: the module's
synthesizer and power amplifier are built for one band. If you have a UHF-populated
board instead, the 70 cm equivalent is 433–435 MHz (a common fox choice is 433.900 MHz)
— check your event's assignment.
Everything else in this README is unchanged; only
frequencydiffers.
To change it: edit frequency, save code.py to the device, Ctrl+D in the shell to reboot.
Each cycle, after the Morse ID, the fox plays a short tune. This makes a fox fun and easy to recognize by ear on a crowded band.
code.py ships with three public-domain tunes, and you switch between them
by commenting and uncommenting. Exactly one is active at a time:
- William Tell Overture finale (Rossini) — the "Lone Ranger" gallop. Active by default.
- Sailor's Hornpipe (traditional) — the "Popeye" tune.
- First Call — the racetrack "call to post" bugle, a fitting fanfare for the start of a fox hunt.
To switch, open code.py, find the song block near the top, comment out the
active tune's SONG_TEMPO and SONG_NOTES (they come as a pair), and
uncomment the one you want. In Thonny you can select the lines and press
Ctrl+3 to toggle comments on or off. Save to the device and Ctrl+D.
Comment the pair together. Each tune has its own
SONG_TEMPOandSONG_NOTES. If you uncomment one tune's notes but leave another's tempo active, the song will play at the wrong speed.
Most of these tunes started out as RTTTL ringtones of public-domain melodies. RTTTL isn't played directly, it's converted to this firmware's MIDI-note format ahead of time.
This firmware stores each melody as a list of (MIDI note, duration) pairs,
with a tempo. A tune looks like this near the top of code.py:
SONG_TEMPO = 180
SONG_NOTES = [
(67, 2), (67, 2), (67, 4), (67, 2), (67, 2), (67, 4), ...
]- The first number is a MIDI note number (60 = middle C, 69 = A above
middle C, …).
0means a rest (silence). - The second number is the note's length in 32nds of a whole note — so
8is a quarter note,4an eighth,16a half,2a sixteenth. A dotted note is 1.5× (a dotted quarter = 12). SONG_TEMPOis the tempo in BPM. Higher = faster.
You can hand-edit SONG_NOTES directly if you're comfortable with MIDI note
numbers. Most people find it easier to start from an RTTTL tune of a
public-domain melody and convert it to the note/duration format above.
A few related settings at the top of code.py:
WPM = 12 # Morse speed (words per minute)
TONE_HZ = 800 # Morse tone pitch, in Hz
ARTICULATION = 0.875 # fraction of each note slot that actually soundsARTICULATION controls how "detached" the song notes sound — lower values
leave a gap between notes (more staccato); 1.0 runs them together.
⚠ This firmware has NO battery protection. It never reads the battery and will keep transmitting until the LiPo is flat — which is bad for the LiPo.
Because of that:
- Deploy fully charged, note the time you deployed, and retrieve promptly. Don't leave it running for days.
- The board blinks its LED once per cycle — that's your "still alive" signal.
transmit_delay = 30000sets the gap between transmissions (in milliseconds; 30000 = 30 seconds). A longer delay means less airtime and longer battery life, but the fox is "findable" less often.
This is the deliberately simple firmware — no battery monitoring, one song, one file — chosen so it's easy to read and reliable. More advanced variants (with battery alerts, multiple songs, sleep modes) exist upstream if you want to grow into them (see Credits).
| Situation | What to do |
|---|---|
| Board / port never shows up | Swap the USB cable first — charge-only cables won't enumerate. |
| Browser flash tool won't connect | Redo bootloader mode (B while plugging in); use Chrome or Edge. |
Connecting...... never ends (esptool) |
Same cause — board isn't in bootloader mode. Redo the B move. |
'python' is not recognized |
Try py -m esptool, or reinstall Python with "Add Python to PATH" checked. |
Flashed OK, but Thonny can't connect / no >>> |
The browser tool or another program may still hold the port — disconnect it / close the tab, replug, retry. |
>>> prompt but Save As has no "CircuitPython device" |
Thonny is on your computer's Python, not the board — check the bottom-right corner; redo Configure interpreter. |
Saved code.py but nothing runs, no output |
Press Ctrl+D in the shell — a held REPL blocks auto-run. |
| Ctrl+D but still silent, no error | Misnamed/misplaced file — View → Files: file at device root, spelled exactly code.py. |
| Thonny: "device is busy" when saving | Ctrl+C in the shell to stop the running code, then save. |
| No drive appeared after flashing | Expected on this board — there is no drive. Use Thonny to confirm success. |
| Callsign warning at boot | You haven't set your real callsign in callmessage (it still contains N0CALL). |
| Linux "permission denied" on the port | sudo usermod -a -G dialout $USER, then log out/in. If the port vanishes, sudo apt remove brltty. |
Everything you'd normally change lives at the top of code.py:
| Setting | Default | What it does |
|---|---|---|
callmessage |
"N0CALL DECOY DECOY N0CALL" |
Your callsign + message, sent in Morse. Set this. |
frequency |
146.565 |
Transmit frequency in MHz. Must be legal for your license/region. |
transmit_delay |
30000 |
Milliseconds between transmissions. |
bandwidth |
1 |
0 = 12.5 kHz, 1 = 25 kHz channel spacing. |
squelch |
3 |
0–8; 0 = open. Receive-side — see note below. |
volume |
5 |
1–8 speaker output level. Receive-side — see note below. |
WPM |
12 |
Morse speed in words per minute. |
TONE_HZ |
800 |
Morse tone pitch in Hz. |
ARTICULATION |
0.875 |
How much of each song-note slot actually sounds. |
STATUS_LED |
True |
One LED blink per cycle when enabled. |
SONG_TEMPO / SONG_NOTES |
William Tell (of 3 built-in) | The active tune. Three public-domain tunes ship in code.py; comment/uncomment to switch. See The song. |
Leave CALLSIGN_PLACEHOLDER = "N0CALL" unchanged — this is the match that will trigger the unset-callsign boot warning.
These two are receive-side settings. The SA868 is a full transceiver, and
squelch sets the threshold at which it opens its receiver, while volume
sets the level of its speaker output. code.py sends both to the module at
startup because they're part of the standard SA868 setup sequence — but this
fox only ever transmits, and no speaker is connected, so changing them won't audibly change anything about the fox's signal.
They're documented here because they're real settings you'll see at the top of
code.py, and because the SA868 is capable of more than this firmware asks of
it. If you extend the fox to listen — a receive-triggered fox, a beacon that
checks whether the frequency is busy before keying — these are the knobs you'd
be reaching for.
If you want the transmitted audio louder or quieter, that's a different
control: modulation level is set by the PWM signal code.py generates on the
MIC pin, not by volume.
This fox is based on the rot13labs / C0ldbru fox (https://github.com/c0ldbru/fox, GPL-3.0), by way of Bradan Lane STUDIO's CircuitPython port (2024), which itself builds on Yet Another Foxbox (YAFB) by Gregory Stoike (KN4CK) (https://github.com/N8HR/YAFB). This "basic deployment" is functionally the C0ldbru design expressed in CircuitPython as a single file.
Distributed under GPL-3.0, WITHOUT ANY WARRANTY. See https://www.gnu.org/licenses/ for the full license text.
If you redistribute or publish this, keep the attribution above and include the GPL-3.0 license — that's a requirement of the license, not just courtesy.
Happy hunting — and 73!
