KI9NG
May 2026  •  x6100 pota js8call firmware amateur radio

POTASpotter v1.0: Self-Spotting from the X6100 Without a Phone

Last August I wrote about an idea: POTA self-spotting via JS8Call directly from the Xiegu X6100, no phone, no laptop, no internet connection at the operating position. That idea shipped in May 2026, timed for Hamvention. This is the full story of how it got built.

There are two separate features in POTASpotter v1.0. The first uses WiFi -- the radio posts a spot directly to the POTA API over HTTP when you have connectivity. The second uses JS8Call over RF -- the radio transmits a JS8 message that gets picked up by a gateway station and relayed to the internet spotting network. Both live in the same dialog in the firmware. You pick which path to use based on what you have available.

The Radio

The X6100 runs Linux on an Allwinner A33 ARM processor. The community firmware -- maintained primarily by gdyuldin on GitHub as a fork of the original r1cbu work -- is a C application built with LVGL, an embedded graphics library. Adding a feature means writing C, cross-compiling with Buildroot, and copying the binary to the radio over SSH.

The radio has a DEV port on the USB-C connector that exposes a serial console and audio. Default login is root. You can SSH in, poke around, push files. It is a genuinely open platform once you get past the initial firmware setup.

The WiFi Path

The WiFi spot feature came first because it was simpler. The radio already had WiFi. The POTA API accepts a straightforward HTTP POST with JSON. The callsign is already stored in the radio's parameter database -- the same one FT8 uses. The frequency and mode come from whatever the radio is currently doing.

The dialog lives at APP → page 3 → POTA Spot. You tap it, enter a park reference, tap SPOT. The radio posts to https://api.pota.app/spot/ and shows you a success or error screen. Spotted parks are remembered and can be re-selected in one tap on the next activation.

One bug burned a few hours: curl_global_init() was never being called before curl_easy_init(). Without that initialization libcurl cannot register its protocol handlers and returns error code 1 with the message "Error", which is not a helpful error message. Once that was fixed the WiFi path worked cleanly.

The JS8 Path

The JS8Call path was the harder and more interesting problem. The idea is that you transmit a JS8 message addressed to a gateway service, any JS8Call station with APRS-IS gating enabled relays it to the internet, and the gateway picks it up and submits the spot to pota.app under your callsign. No phone. No WiFi. Just HF.

JS8Call is a weak signal digital mode built on the same foundation as FT8 but designed for messaging. There is an existing public service called APSPOT that listens for JS8 messages addressed to it on APRS-IS and submits POTA spots when it sees one in the right format. Gated JS8 traffic from stations all over HF reaches APRS-IS constantly. You just need to be heard by one of them.

The JS8 Engine

To transmit JS8 the firmware needs to encode a text message into the right tone sequence and generate audio. I did not want to port the full JS8Call application -- it is Qt-based, large, and contains a lot of things irrelevant to a radio with no keyboard and no mouse.

The right answer turned out to be fate, a minimal pure-C++ JS8 implementation by Robert Morris AB1HL. It is about 5500 lines covering encode, modulate, decode, and demodulate. No Qt dependencies. No Fortran for the decoder. Clean C++17. I ported it to the X6100 as a shared library called libx6100js8 and packaged it as a Buildroot package so it compiles as part of the firmware build.

The encoder takes a text message and produces raw 48 kHz PCM audio. That audio goes to PulseAudio, which sends it to the radio's baseband audio path and out the PA as RF.

The Wire-Up Bugs

Connecting the encoder to the UI dialog exposed four bugs in the integration. All of them were found and fixed during the wire-up session on May 9.

The first was a stale library in the sysroot. The Buildroot sysroot had an old version of libx6100js8 that predated the POTA spot encoder function. The newer version was already in the build cache but had not been copied into the sysroot. Fixed by manually copying the right .so and updating the symlinks.

The second was a sample rate mismatch. The JS8 encoder outputs 48000 Hz PCM. PulseAudio on the X6100 runs at 44100 Hz. Playing 48 kHz content at 44100 Hz pitches the signal up by about 8.8%, which means the receiving end hears something that is not valid JS8. Fixed with an inline linear resampler -- about 40 lines of C, 48000 to 44100 at ratio 160/147.

The third was a framing problem. The original implementation streamed all encoded frames as one continuous buffer with PTT held the entire time. JS8 Normal mode uses 15-second transmission slots with a roughly 2.4-second gap between frames where PTT must drop. Transmitting without those gaps produces undecoded garbage on the receiving end. Fixed with a slot-aligned transmit loop that waits for the next 0/15/30/45-second boundary before each frame and drops PTT between them.

The fourth was a missing guard. If you have never set a callsign in the radio's FT8 settings, the callsign field is empty. Passing an empty string to the encoder produces a garbage frame. Fixed with a check at the start of the transmit function that shows an error message if the callsign is not set.

The Gateway Problem

The original research pointed at POTAGW as the gateway service to use. Live testing on May 9 showed POTAGW was not working. The APSPOT homepage notes it had been down for a long time. The working service is APSPOT.

APSPOT also uses a different message format than POTAGW. The body format is:

@APRSIS CMD :APSPOT   :! POTA US-0765 14.225 SSB CQ POTA

Key differences from what the original research had: the destination callsign is APSPOT not POTAGW, padded to exactly 9 characters with three trailing spaces. The body starts with ! POTA. The frequency is in MHz with a decimal point, not integer kHz. Your callsign is not in the body at all -- APSPOT reads it from the AX.25 source callsign that the JS8 gateway puts in the APRS packet.

A typical POTA spot message for a 32-character body runs about 5 JS8 Normal frames, which is roughly 75 seconds of on-air time. The status bar counts down to each slot boundary and shows which frame is transmitting.

The UX

From the operator's perspective the flow is:

The ATU can optionally retune after the QSY before transmitting. The status bar shows the countdown to slot boundary, which frame is currently transmitting, and a confirmation when the message is complete.

Deploying to the Radio

Deploying to the X6100 is its own process. The radio runs a watchdog daemon that immediately respawns the GUI if it exits. You have to kill the daemon first, push the new binary and library over SCP, then restart the daemon. If the daemon respawns before you finish the SCP transfer the binary is locked and the copy fails.

There is also a fast path for when you do not want to wait for a full 30-minute Buildroot rebuild. You can inject the new binary directly into the rootfs ext4 image with debugfs and write it back to the SD card partition with dd. That takes about 30 seconds instead of 30 minutes once you know the right sector offset.

Repos

The code is spread across three repos:

Like everything else I build, I did not write the code. I knew what I wanted, I worked through the problems as they came up, and Claude wrote the implementation across a series of sessions spanning several months. The firmware architecture, the JS8 protocol research, the bug hunting -- all of that was collaborative. The radio is mine and the ideas are mine. The C is Claude's.

73
Bill KI9NG  |  EN61  |  DMR 3200395
← back to field notes