OTA WebSocket API
OTA exposes a full WebSocket API on TCP port 2103 (default, configurable in Preferences). Everything you can do from the GUI can be done from the API.
Connection
ws://127.0.0.1:2103
The server accepts plain-text WebSocket connections. All messages are UTF-8 JSON text frames.
Preferences → WebSocket API controls:
- ►Enable/disable the API
- ►Interface:
127.0.0.1(localhost only) or0.0.0.0(all interfaces) - ►Port (default 2103)
Message Format
Client → Server (commands)
{
"type": "cmd",
"id": "optional-correlation-id",
"cmd": "command.name",
"data": { ... }
}
Server → Client (replies)
{ "type": "reply", "id": "...", "ok": true, "data": { ... } }
{ "type": "reply", "id": "...", "ok": false, "error": "description" }
Server → All Clients (events)
{ "type": "event", "event": "event.name", "data": { ... } }
On Connect
Immediately after connecting you receive two messages:
- 1.
helloevent — identifies the server - 2.
statusevent — current state snapshot
Spot Objects
Many commands and events include spot objects:
{
"key": "W4ABC|K-1234|14025",
"source": "SOTA",
"callsign": "W4ABC",
"reference": "K-1234",
"reference_name": "Springer Mountain",
"freq_khz": 14025.0,
"mode": "CW",
"spot_time": "2025-01-15T14:32:00Z",
"spotter": "W5XYZ",
"comments": "59 QSB",
"grid": "EM84",
"status": 0,
"status_str": "",
"lat": 34.627,
"lon": -84.191
}
Status values:
| Value | Meaning |
|---|---|
| 0 | Unknown (not yet evaluated) |
| 1 | Heard (you can hear this station) |
| 2 | Contacted (you worked this station) |
| 3 | NotHeard (couldn’t hear) |
lat/lon are only present when the fetcher provides location data.
Commands
status.get
Response data:
{
"radio_connected": false,
"radio_freq_khz": 14025.0,
"radio_mode": "CW",
"callsign": "W5XYZ",
"visible_spots": 42,
"total_spots": 67,
"ws_port": 2103,
"ws_clients": 1
}
config.get
Response data:
{
"callsign": "W5XYZ",
"sotaRef": "W5N/SP-001",
"maxAgeMins": 30,
"refreshIntervalSecs": 60,
"wsEnabled": true,
"wsPort": 2103,
"wsHost": "127.0.0.1"
}
config.set
Partial settings update. Supported fields: maxAgeMins, refreshIntervalSecs.
Request data:
{ "maxAgeMins": 60, "refreshIntervalSecs": 120 }
spots.get
Returns currently visible spots (respects all active filters).
Response data:
{
"spots": [ { spot object }, ... ],
"count": 42
}
spots.get_all
Returns all spots regardless of filters (including hidden Contacted/NotHeard spots). Same response shape as spots.get.
spot.tune
Tune the radio to a spot. Requires radio to be connected.
Request data:
{ "key": "W4ABC|K-1234|14025" }
spot.status.set
Mark a spot’s status.
Request data:
{ "key": "W4ABC|K-1234|14025", "status": 1 }
Status values: 0=Unknown 1=Heard 2=Contacted 3=NotHeard. A spot.status.changed event is pushed to all clients.
spot.log
Log a contact. Appends a JSON line to ~/ota-log.json.
Request data:
{ "key": "W4ABC|K-1234|14025" }
filter.get
Returns current filter state.
Response data:
{
"sota": true,
"pota": true,
"wwff": true,
"wwbota": true,
"bota": true,
"gma": true,
"dx": false,
"mode_ssb": true,
"mode_cw": true,
"mode_am": true,
"mode_fm": true,
"mode_other": true,
"status_mode": -2,
"max_age_mins": 30
}
status_mode: -2=active only, -1=all, 0=Unknown, 1=Heard, 2=Contacted, 3=NotHeard.
filter.set
Partial filter update. Any subset of filter.get keys is accepted.
Request data (example — show only CW spots):
{ "mode_ssb": false, "mode_am": false, "mode_fm": false, "mode_other": false }
Supported fields: sota, pota, wwff, wwbota, bota, gma, dx, mode_ssb, mode_cw, mode_am, mode_fm, mode_other, status_mode, max_age_mins, search. A filter.changed event is pushed to all clients.
radio.get
Response data:
{ "connected": true, "freq_khz": 14025.0, "mode": "CW" }
radio.connect
Connect to the rig using the configured radio settings.
radio.disconnect
Disconnect from the rig.
radio.frequency.set
Request data:
{ "freq_khz": 14025.0 }
radio.mode.set
Set the VFO mode. Valid modes: USB, LSB, SSB (auto), CW, CWR, FM, NFM, AM.
Request data:
{ "mode": "CW" }
radio.volume.set
{ "volume": 75 }
radio.power.set
{ "power": 100 }
radio.mute
{ "muted": true }
radio.filter.set
{ "filter": 1 }
0=narrow, 1=normal, 2=wide.
radio.keyspeed.set
Set CW keyer speed.
{ "wpm": 20 }
radio.tune
Trigger an ATU tuning cycle.
refresh
Force an immediate refresh from all enabled spot sources.
version.get
Response data:
{
"version": "0.2.0-BETA",
"app": "OTA",
"build_time": 1748000000
}
Events
hello
{ "version": "0.2.0-BETA", "port": 2103, "app": "OTA" }
status
Pushed at 1 Hz. Same fields as status.get.
spots.updated
Pushed whenever the spot list changes (after any fetch). Includes full filtered spot list.
{ "spots": [ { spot object }, ... ], "count": 42 }
spot.status.changed
{ "key": "W4ABC|K-1234|14025", "status": 1 }
radio.connected
{ "freq_khz": 14025.0, "mode": "CW" }
radio.disconnected
{}
radio.frequency
Pushed at ~2 Hz while radio is connected.
{ "freq_khz": 14025.0, "mode": "CW" }
filter.changed
Pushed when filters change. Same shape as filter.get.
config.changed
Pushed when config changes. Same shape as config.get.
cm.connected
Pushed when the CodeMonkey bridge connects. Indicates that radio control has been handed off to CodeMonkey; OTA’s own Hamlib is suspended.
{ "host": "127.0.0.1", "port": 2104 }
cm.disconnected
Pushed when the CodeMonkey bridge disconnects (or CodeMonkey exits). OTA automatically resumes controlling the radio via its own Hamlib after this event.
{}
error
{ "message": "description of error" }
Example: Remote Control via Python
import asyncio, json, websockets
async def main():
async with websockets.connect("ws://127.0.0.1:2103") as ws:
# Drain initial hello + status
for _ in range(2): await ws.recv()
# Get all visible SOTA spots
await ws.send(json.dumps({"type": "cmd", "id": "1", "cmd": "spots.get"}))
reply = json.loads(await ws.recv())
spots = reply["data"]["spots"]
sota = [s for s in spots if s["source"] == "SOTA"]
print(f"Found {len(sota)} SOTA spots")
# Tune to the first CW spot
cw = [s for s in sota if s["mode"].upper() == "CW"]
if cw:
spot = cw[0]
print(f"Tuning to {spot['callsign']} on {spot['freq_khz']} kHz")
await ws.send(json.dumps({
"type": "cmd", "id": "2",
"cmd": "spot.tune",
"data": {"key": spot["key"]}
}))
# Stream events
async for msg in ws:
ev = json.loads(msg)
if ev.get("event") == "radio.frequency":
print(f"Radio: {ev['data']['freq_khz']:.1f} kHz {ev['data']['mode']}")
elif ev.get("event") == "spots.updated":
print(f"Spots updated: {ev['data']['count']} visible")
asyncio.run(main())
Integration with CodeMonkey
Built-in Bridge (recommended)
OTA has a built-in CodeMonkey bridge under Radio → CodeMonkey Bridge…. When enabled:
- ►OTA connects to CodeMonkey’s WebSocket API (default
127.0.0.1:2104). - ►OTA suspends its own Hamlib and hands radio control to CodeMonkey.
- ►All radio commands (
radio.frequency.set,radio.mode.set,spot.tune, etc.) are forwarded to CodeMonkey via the bridge. - ►When tuning to a CW spot, CodeMonkey automatically adjusts the VFO so the received signal falls under the decoder’s tone marker:
VFO = spot_freq_khz − (decoder_tone_hz / 1000.0) - ►If CodeMonkey exits, OTA automatically reverts to its own Hamlib.
- ►
cm.connected/cm.disconnectedevents are broadcast to all WS clients so your application knows which mode is active.
Bridge Protocol (advanced / custom controllers)
If you are building a custom controller that replaces CodeMonkey, these are the commands OTA sends to the bridge target:
| OTA sends (cmd) | Data |
|---|---|
| link.announce | {"controller": "OTA"} — sent immediately on connect |
| link.tune | {"freq_khz": 14025.0, "mode": "CW"} — tune + CW offset |
| link.release | {} — sent on OTA disconnect/bridge disable |
The bridge target should send link.state events back:
{ "event": "link.state", "data": { "active": true, "controller": "OTA" } }
Python: monitoring bridge state
import asyncio, json, websockets
async def monitor():
async with websockets.connect("ws://127.0.0.1:2103") as ws:
for _ in range(2): await ws.recv() # drain hello + status
async for msg in ws:
ev = json.loads(msg)
if ev.get("event") == "cm.connected":
print(f"CodeMonkey bridge up: {ev['data']['host']}:{ev['data']['port']}")
elif ev.get("event") == "cm.disconnected":
print("CodeMonkey bridge down — OTA using own Hamlib")
elif ev.get("event") == "radio.frequency":
print(f"Radio: {ev['data']['freq_khz']:.3f} kHz {ev['data']['mode']}")
asyncio.run(monitor())
Python: automated CW contact logging
Listen to char.decoded from CodeMonkey and spot.status.set in OTA to mark contacts:
import asyncio, json, websockets
async def hunt():
async with websockets.connect("ws://127.0.0.1:2103") as ota, \
websockets.connect("ws://127.0.0.1:2104") as cm:
for _ in range(2): await ota.recv()
for _ in range(2): await cm.recv()
decode_buf = ""
async def read_cm():
nonlocal decode_buf
async for msg in cm:
ev = json.loads(msg)
if ev.get("event") == "char.decoded":
decode_buf += ev["data"]["char"]
asyncio.ensure_future(read_cm())
async for msg in ota:
ev = json.loads(msg)
if ev.get("event") == "spots.updated":
spots = ev["data"]["spots"]
cw = [s for s in spots if "CW" in s.get("mode","").upper()]
if cw:
spot = cw[0]
await ota.send(json.dumps({
"type": "cmd", "cmd": "spot.tune",
"data": {"key": spot["key"]}
}))
print(f"Tuning to {spot['callsign']} {spot['freq_khz']:.3f} kHz")
decode_buf = ""
asyncio.run(hunt())