Web API Reference¶
The Tab5 runs an HTTP server that exposes a REST API for remote control and diagnostics. All endpoints are on port 80.
Base URL¶
http://<tab5-ip-address>/api/
Example: http://192.168.1.50/api/status
Status Endpoints¶
GET /api/status¶
Returns the current state of the panadapter.
Response (JSON):
{
"battery": { "level": 85, "mv": 8000, "charging": false },
"wifi": { "ssid": "MyNet", "rssi": -65, "ip": "192.168.1.50" },
"freq_hz": 14074000,
"qmx_fw": "1_03_002",
"mode": "DIGI",
"band": "20m",
"screen": "ft8",
"ft8": { "st": "armed", "text": "TX armed: CQ OZ1LAV JO65 - call 2 of 4 (~7s)" },
"passband_hz": 2700,
"signal_dbm": -83.5,
"zoom": 1.0,
"pan_bins": 0,
"cw_pitch_hz": 700,
"if_cal_hz": 0,
"flat_mode": false,
"utc_epoch": 1785340050,
"qso_count": 42,
"qrz_key_set": true,
"eqsl_creds_set": false,
"lotw_ready": true,
"bandplan": { "lo": 14000000, "hi": 14350000, "segs": [ { "lo": 14000000, "hi": 14070000, "c": "20B0FF", "l": "CW" } ] },
"bt": { "en": true, "conn": false },
"tab5_fw": "v1.7.2",
"bands": [ { "name": "20", "center_hz": 14100000 } ]
}
Notes:
screenis"panadapter"or"ft8".ft8is present only while the FT8/FT4 screen is live — the engine does not run otherwise, so its text would be stale.stis one ofactive/armed/done/timeout/wait/idle, andtextis the same line the Tab5 shows on its own TX status label. This is what drives the browser's TX banner and the red dot in the tab title.btis the Bluetooth mouse:enis whether it is switched on,connwhether a mouse is connected right now.en: true, conn: falsemeans it is scanning or waiting for a paired mouse to come back.signal_dbmandbandplanarenullwhen unavailable (no spectrum yet; VFO outside a known band).passband_hzfalls back to a per-mode default until CAT reports a real width.
Control Endpoints¶
POST /api/cmd¶
Send a command to the panadapter. The command name is the action key; each
action names its own parameter key (there is no generic value).
Request (JSON):
{
"action": "set_freq",
"hz": 14074000
}
Supported actions:
| Action | Parameter | Effect |
|---|---|---|
set_freq |
hz |
Set VFO frequency |
set_band |
hz (band centre) |
Switch band — recalls the frequency last used on it |
set_mode |
mode: "USB"/"LSB"/"CW"/"DIGI"/"AM" |
Change mode (AM needs QMX firmware 1.04+) |
set_bw |
hz |
Filter width — SSB at 1000 Hz and above, CW passband below |
set_zoom |
zoom: 1.0/2.0/4.0/8.0 |
Zoom level |
set_rit |
hz |
RIT offset. Retuning clears it |
set_screen |
screen: "panadapter"/"ft8" |
Switch the Tab5's own view. Applied within about a second (handed to the display task) |
set_ft8_mode |
mode: "ft8"/"ft4", optional freq_hz |
Switch between FT8 and FT4, optionally retuning. Omit freq_hz to keep the current frequency. This is an action rather than a setting because it also retunes the radio and clears stale decodes — read ft8_op_mode back from /api/settings to confirm |
set_activation |
ref |
Set the POTA/SOTA activation reference |
cq_start |
(none) | Start a CQ run, as the Tab5's own Call CQ button does. Keys the radio. Only acted on while the Tab5 is in FT8/FT4 mode; otherwise discarded, not queued |
reply |
call |
Work a decoded station — the same intelligent Transmit a Tab5 row-tap runs. Keys the radio. Outcome in /api/status ft8.web_r |
qso_override |
what: "resend"/"rr73"/"73"/"cancel" |
Mid-QSO override, as the Tab5's own three buttons do. Keys the radio except for cancel |
clear_swr |
(none) | Clear a latched SWR-protection trip, same as tapping the Tab5's own fault prompt. TX stays refused until this is called |
tune_start / tune_stop |
(none) | Antenna Tune (QMX 1.04+ only). Keys the radio continuously; 60 s safety stop on the device; live power/SWR in /api/status tune while running |
pause / resume |
(none) | Release the radio so its own menus can be used, and take it back. While paused the CAT poll, the dead-stream watchdog and the stuck-decode watchdog all stand down |
greylist_clear |
(none) | Un-skip every grey-listed station |
ota_install |
url |
Download and install firmware. Refused while transmitting or mid-QSO, with the reason in the reply body. Does not restart on its own |
ota_reboot |
(none) | Restart into firmware already installed |
usb_shutdown |
(none) | Orderly USB teardown before a reflash: radio to RX, CAT and audio closed, VBUS dropped |
usb_replug |
(none) | Power-cycle the USB port to recover a wedged device |
power_off |
(none) | Power the Tab5 down |
reset_settings |
(none) | Clear stored settings back to defaults |
reset_network |
(none) | Clear the stored WiFi/network state |
Radio menus (the QMX's own terminal, on its second serial port):
open, close, key, text, term_view, qmx_ports, qmx_term_probe —
see Radio menus and GET /api/term.
Developer actions. Not referenced by any web-UI element, and easy to regret:
| Action | Effect |
|---|---|
cat_raw |
Send one raw CAT string to the radio |
drawer |
Open the settings drawer, optionally at scroll_y, so a section below the fold can be screenshotted |
resmon |
Toggle the resource-monitor overlay |
time_redetect |
Re-arm the once-per-boot QMX GPS detection without rebooting |
help |
Open the on-device manual at a topic |
panic_test |
Crash the device deliberately, to prove the crash recorder works. ⚠ Radio off only — a panic reboot is a warm reset, which leaves the QMX needing a power cycle |
⚠ Check the response BODY, not the status code. The reply is {"ok":true}
on success — but an unrecognised action returns HTTP 200 with the body
unknown action, and a refused one (for example ota_install while
transmitting) returns {"ok":false,"error":"..."}, also with HTTP 200. A caller
that only tests the status code cannot tell success from a typo, and a mistyped
action is then a silent no-op.
Effects are applied asynchronously — mode and filter writes are queued onto the
CAT poll task, and cq_start, reply, qso_override and set_ft8_mode onto
the display task — so read /api/status or /api/settings to see the result
rather than assuming it from the response.
GET /api/tone¶
The TX audio tone and the live occupancy of the 200-2800 Hz window.
{ "hz": 1650, "hold": false, "min": 200, "max": 2800, "step": 50,
"stations": 14, "busy": "0000000000111110...", "partner_hz": 1750, "busy_tx": false }
busy is one character per 50 Hz slot (1 = a station or its guard band), sent
as a string because 52 bits do not survive JavaScript's number type.
busy_e and busy_o carry the EVEN and ODD windows separately — two
stations only collide in the same window, and only the operator knows which one
they are about to transmit into; busy remains the our-window/union view the
automatic verdict uses.
stations is how many decoded stations fed the mask - zero means the band
picture is unknown, not empty, and a caller must say so rather than showing the
all-clear.
POST /api/tone¶
{"hz":1650} and/or {"hold":true}. Out-of-range frequencies are clamped.
A tone change while a QSO is running is applied to the running exchange first (your partner tracks your slot, not your tone). Mid-burst it is refused with 409 and the body carries the device's own reason - the preference is not stored either, so the two can never end up half-committed.
GET /api/memory¶
All 32 memory channels: {"slots":[{"idx":0,"occupied":true,"freq_hz":14074000,"mode":"DIGI","label":"FT8"}, ...]}.
Empty slots carry only idx and occupied:false.
POST /api/memory¶
Write one channel - {"idx":3,"freq_hz":14074000,"mode":"DIGI","label":"FT8"} -
or clear one with {"idx":3,"clear":true}. Fields you omit keep their stored
value, so renaming a channel needs only idx and label.
A frequency outside a recognised amateur band returns 400 - the same refusal the Tab5 makes, so the browser cannot be a way around it.
There is no recall endpoint: recalling a channel is set_freq plus set_mode,
which /api/cmd already does.
GET /api/settings¶
The settings a browser can usefully edit: callsign, grid, the CQ presets, the FT8
filters, the everyday toggles, QMX volume, band-plan region and the WiFi SSID.
The spot sources (spots_en overall, then rbn_en and cluster_en),
propagation feedback (psk_rx_en) and the Bluetooth mouse (bt_mouse_en) are
here too — bt_mouse_en is stored immediately but only takes effect after a
restart. swr_limit_x10 (the SWR limit × 10, so 30 is 3.0:1) is reported
but not writable — a transmit safety limit is set at the device.
The WiFi password is never returned - only wifi_pass_set says whether one is
stored.
POST /api/settings¶
Applies a partial update: only the keys present are touched, everything else is left alone. So a single toggle is a one-key body, and a stale browser form can never reset fields it did not show.
{ "my_grid": "JO65ab", "rbn_en": true, "cq": { "max_calls": 10 } }
wifi_ssid and wifi_pass take effect only when both are supplied - an empty
password means "keep the stored one", never "erase it". qmx_vol_db is in dB
(0-50) and is sent to the radio as well as stored.
Everything the settings drawer can change is reachable here. Besides the keys
above that includes flat_mode, distance_in_miles, greylist_en,
ft8_early_decode, field_day_en (with fd_class / fd_section), spots_en,
sota_en, rbn_en, pskreporter_en, resmon_en, tx_tone_hold, tx_tone_hz,
cw_pitch_hz and cw_cal_hz — all readable back from GET.
sim_mode_en is the one to know about if you are automating tests. With it
on, the firmware runs complete FT8 exchanges against phantom stations and
ft8_tx.c hard-refuses to key the radio, so a QSO can be driven end to end with
no antenna and no QMX attached. ⚠ Simulated contacts land in the real ADIF
log, so turn it off afterwards and delete them — they have FREQ=0, and the
ADIF viewer's "Del N test" button finds exactly those.
Read-only reporters. ft8_op_mode ("ft8" / "ft4") and ft8_freq_hz are
returned but not writable here — change them with the set_ft8_mode action,
which also retunes the radio and clears stale decodes, then read these back to
confirm. swr_limit_x10 is likewise reported only.
GET /api/decodes¶
The FT8/FT4 decode list as the Tab5 shows it - same ordering (the station being worked, then messages addressed to you, then CQ calls, then strongest signal) and the same Filter-window hides applied. Read-only.
{
"mode": "FT8",
"miles": false,
"working": "DK7CVD",
"rows": [
{ "call": "DK7CVD", "text": "OZ1LAV DK7CVD -07", "grid": "JO31",
"snr": -7, "hz": 1503, "dt": 210, "age": 6, "heard": 3,
"km": 612, "brg": 173,
"sl": "E", "me": true, "cq": false, "pin": true }
]
}
dt is milliseconds, hz the station's audio tone, age seconds since it was
last decoded, sl the transmit window it was heard in. me marks a message
containing your callsign, pin the station you are working.
km and brg (v1.8.3) are distance and bearing from your grid to theirs, worked
out on the device with the same maths the Tab5's own list uses. Both are omitted
entirely when either grid is missing — there is no null and no zero, so a client
should render an absent field as "unknown" rather than as a distance. km is in
miles when the top-level miles flag is true, which mirrors the Distance in
miles setting.
GET /api/help¶
The guidance rows, ranked by the device from its own live state. flagged marks a
row the firmware can see is happening right now.
{
"context": { "page": "guide/panadapter.md", "anchor": "Layout", "label": "Panadapter" },
"rows": [
{ "symptom": "My radio is not showing up", "flagged": true,
"page": "reference/troubleshooting.md", "anchor": "won't reconnect",
"label": "Radio not connecting" }
]
}
context is the chapter the Tab5's own User Manual button would open right
now. anchor is a case-insensitive heading substring, not a URL fragment.
GET /api/manual?page=guide/ft8-tx.md¶
One page of the built-in manual, as the raw markdown that built the documentation
site. page=toc.json returns the contents list. Served from the firmware, so it
needs no internet and always matches the running version. 404 if the page is not
in this firmware's manual.
CAT Endpoints¶
There is no raw-CAT HTTP endpoint. Send CAT-level changes through
POST /api/cmd (set_freq, set_mode, set_bw, set_band), which routes them
through the CAT poll task - the only writer to the radio's serial port. The web
UI's own CAT box uses those actions.
FT8 Endpoints¶
The decode list is GET /api/decodes (above). Transmit from the browser is
limited to POST /api/cmd {"action":"cq_start"}; there is no endpoint that sends
an arbitrary FT8 message, deliberately - replies are chosen from the decode list
in front of you, at the Tab5.
ADIF Endpoints¶
GET /api/adif¶
Download ADIF log.
Response: ADIF file (text/plain)
?activation=<REF> limits the download to contacts made during an activation of
that reference (matched on MY_SIG_INFO), so a park's log can be uploaded on its
own rather than the whole file. An unknown reference returns a valid ADIF file
with no records, not an error.
?date=today or ?date=YYYYMMDD limits it to one UTC day, and names the download
qso-YYYY-MM-DD.adi so a daily file is self-identifying once it is saved. A day
with no contacts returns a valid empty ADIF, not an error.
POST /api/adif/edit¶
Correct one field of one record: ?idx=<n>&call=<CALL>&field=<F>&value=<V>.
idx is the record's position in the file and call must match the callsign
already stored there — both, or the device answers 409 and changes nothing, so
a browser showing a stale list cannot edit the wrong QSO.
Only three fields are accepted: RST_SENT, RST_RCVD and SIG_INFO. An empty
value removes the field, which for a report is the honest record of one that
was never exchanged. SIG_INFO is the reference the other station was
activating (US-1241, G/LD-049, DLFF-0123); the device derives SIG from
its shape and writes it alongside, and clears it again when the reference is
cleared. Everything else is refused with 400: callsign, band, mode, date and
time are what QRZ, eQSL and LoTW match a contact on, and a LoTW record is signed
over exactly those.
Response: {"ok":true}, or {"ok":false,"error":"…"}
POST /api/adif/clear¶
Clear all QSOs from ADIF log.
Response:
{
"status": "cleared",
"count": 42
}
Configuration Endpoints¶
GET /api/config¶
Download current configuration (all settings + memory channels).
Response: INI text file
POST /api/config¶
Upload a configuration file to restore settings.
Request: the config file as the raw request body (not multipart)
Response:
{
"status": "imported",
"merged": true
}
Logging Endpoints¶
GET /api/log¶
Download diagnostic log.
Response: Text file (qmx-log.txt)
GET /api/log/saved¶
The flash-persisted copy of the diagnostic log from before the last reboot or power-off - the one that survives a crash in the field.
Response: Text file
GET /api/signal¶
The signal level alone, for a faster S-meter than the 1 Hz /api/status poll can
give. Same DSP call and parameters as /api/status and the Tab5's own S-meter -
only the cadence differs.
{ "dbm": -83.5 }
GET /api/psk_rx¶
Propagation feedback — which receivers have copied your call, from PSK Reporter.
{ "enabled": true, "age_s": 96, "receivers": 14, "max_km": 7412,
"reports": [
{ "call": "W1ABC", "grid": "FN42", "dxcc": "United States", "mode": "FT8",
"f": 14074812, "t": 1785340050, "snr": -14, "km": 6031, "brg": 291 }
] }
Requesting this asks for a refresh, but the collector's own five-minute floor
is enforced on the device, so polling faster than that returns the same data with
a larger age_s rather than fetching again. age_s is -1 before the first
successful query.
snr is omitted when the receiver gave none — 0 dB is a real report, so it
cannot double as "unknown". km and brg are omitted together when the
receiver's grid is unknown.
Off by default. The endpoint always answers: with the feature disabled nothing is
fetched, so enabled is false and reports holds whatever the last query
returned before it was switched off — read age_s rather than assuming the list
is current. It is also empty while offline or before a callsign is set.
POST /api/adif/delete?idx=&call=¶
Delete one QSO. Both the index and the callsign must match the record, or the
request is refused with 409 - so a stale browser view can never delete the wrong
contact.
GET /api/upload_status¶
Progress of a running QRZ/eQSL/LoTW upload (busy, kind, uploaded, failed,
error).
Upload Endpoints¶
POST /api/qrz_key¶
Set QRZ API key (stored server-side).
Request:
{
"api_key": "1a2b3c4d5e6f..."
}
POST /api/qrz_upload¶
Upload ADIF to QRZ Logbook.
Response:
{
"uploaded": 5,
"failed": 0,
"error": null
}
POST /api/eqsl_creds¶
Set eQSL username and password.
Request:
{
"username": "oz1lav",
"password": "secretpass"
}
POST /api/eqsl_upload¶
Upload ADIF to eQSL.
Response:
{
"uploaded": 5,
"failed": 0,
"error": null
}
POST /api/lotw_cert¶
Store the LoTW callsign certificate and key (extracted from the TQSL .p12 in the browser during LoTW setup).
POST /api/lotw_upload¶
Sign all pending QSOs with the stored certificate and upload them to lotw.arrl.org.
Response:
{
"uploaded": 5,
"failed": 0,
"error": null
}
GET /api/lotw_tq8¶
Download the whole log as a signed .tq8 file, for manual upload to LoTW or offline verification.
Response: .tq8 file (gzipped, signed)
WebSocket (Streaming)¶
The web UI uses WebSocket for live spectrum streaming:
ws://<tab5-ip>:80/ws
The server streams FFT magnitude bins and waterfall data every ~100 ms. This is handled automatically by the web UI — you don't need to implement it yourself unless building a custom client.
Error Responses¶
All endpoints return errors in a standard format:
{
"error": "CAT timeout",
"status": 500
}
Common HTTP status codes:
- 200 — Success
- 400 — Bad request (invalid command)
- 500 — Server error (QMX not responding, etc.)
- 503 — Service unavailable (WiFi off, etc.)
Example: Tune to 7.074 MHz in Python¶
import requests
ip = "192.168.1.50"
api = f"http://{ip}/api"
# Set frequency
requests.post(f"{api}/cmd", json={"cmd": "set_frequency", "value": 7074000})
# Set mode
requests.post(f"{api}/cmd", json={"cmd": "set_mode", "value": "USB"})
# Check status
status = requests.get(f"{api}/status").json()
print(f"Now on {status['frequency_hz']} Hz, {status['mode']} mode")
Next: See Troubleshooting for API debugging.