Ordo Artificum / OTA / WebSocket API
← Back to OTA

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:

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. 1.hello event — identifies the server
  2. 2.status event — 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
0Unknown (not yet evaluated)
1Heard (you can hear this station)
2Contacted (you worked this station)
3NotHeard (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:

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())