Skip to content

Repository files navigation

Course Ops

Course Ops

Ham radio event tracking and communications Track the course, run the net.

A web map for marathon-style events: race courses and aid stations from the organizer's KML, KMZ or GPX files, overlaid with live APRS positions of the ham radio operators supporting the event.

There are phone apps, and there are full situational-awareness platforms like TAK. This aims at the gap between them — something a radio club can stand up for a race without much effort.

Status: early development. APRS-IS ingest, KML/KMZ/GPX course import, the live map, incidents, lead runner tracking and a browser setup application all work. Places can also be put on the map by hand, so an event whose organizer supplies no file - a parade, a 5K, a vehicle race - still works. See releases for the Windows download. The APRS-IS feed has been run against real traffic; it has not yet run a live event.

Documentation

Start with the guides. They are written for the people using the app rather than for developers, with screenshots throughout, and they ship inside the app: every running copy serves them at /help/, and the ? on every screen opens the page for the link that person is holding. The same pages are readable here:

Page For
Setting up an event The club officer standing the event up: the course, then roster and links, then race week
Making it yours The parts with no fixed list - layers, roles, races, links - and what clubs can do with them
The basics Everybody: the map, the status colours, the panels, the phone layout
Net Control · SAG · Liaison · Logistics · Staff One page per link, to send out with it

They live in src/courseops/guides/ as Markdown, rendered by the app itself, so a club's volunteers read the guide for the version their club is running - and a change to a guide is a change to the app, reviewed the same way.

The rest is in the repository, aimed at whoever is working on it:

What it will do

  • Show the Full, Half and 10K courses plus aid stations on one map
  • Track sweeps, SAG and rovers live, with no page refresh
  • Give Net Control a roster panel showing who is on station and who has gone quiet
  • Give the Public Safety liaison the same map on a phone, filtered to what matters
  • Let NCS drop pickup pins tracked by bib number, and give SAG their own view of the pickup queue - orderable by which one is nearest the vehicle
  • Record course notes on the map (a blocked intersection, a confusing turn) for the organizer to read after the event
  • Put the organizer's own layers on the map - mile markers, medical, traffic control, portable toilets - naming each one yourself, with its own icon, colour and on/off switch. There is no fixed list and no limit
  • Track people without a callsign - bike medics, race staff, drivers - with a free tracking app on their own phone (OwnTracks or Traccar Client): one QR code for the event, a short designator each, and the roster as the allowlist

Design notes

  • Receive-only. This application never transmits. It logs into APRS-IS with passcode -1, which grants read access and no transmit capability. You need a callsign; you do not need a passcode, and should not supply one.
  • APRS-IS rather than a radio/TNC. This covers operators using phone apps as well as RF trackers reaching the network through igates, with no hardware.
  • Good network citizenship. One connection for the whole server, a server-side filter (your roster's callsigns plus a radius around the course), and backed-off reconnects.
  • Privacy. Only rostered operators — people who consented by signing up — are stored. Anyone else heard near the course is held in memory and shown only to Net Control, so a volunteer using a borrowed radio can be matched to their roster entry; nothing about them is written down until that happens.

Requirements

Python 3.11 or newer, and nothing else. Six runtime dependencies, installed for you by the command below: aprslib, defusedxml, fastapi, uvicorn, python-multipart and segno (the QR code on the Tracking tab). No database server, no npm, no build step.

You also need your own callsign. The app connects to APRS-IS to listen; the passcode stays -1, which grants read access and no transmit capability. It never transmits.

Getting started

Windows: download and run

Grab CourseOps.exe from the latest release and double-click it. One file, nothing to install, no Python. It opens a console window that prints the setup address and, the first time, a setup code - keep that window open, the first form asks for the code - and keeps its database in %LOCALAPPDATA%\CourseOps so nothing is scattered next to your download.

Windows may warn that the publisher is unknown - the build is not code-signed.

To track people live you also need your callsign in a file named .env beside the executable:

APRS_CALLSIGN=W1AW

Everything else - the map, the course import, the roster, the whole setup UI - works without it, and it will tell you that live tracking is off rather than refusing to start.

A Raspberry Pi works too, with no special steps - see docs/DEPLOYMENT.md.

Linux, macOS, or a server: pip

pip is universal here, so there is no separate download. Six commands from nothing to a running server - the exact steps, run against a clean clone and an empty virtualenv.

git clone <repo-url>
cd CourseOps

python -m venv .venv
.venv/Scripts/python -m pip install -e .     # Linux/macOS: .venv/bin/python

copy .env.example .env                       # Linux/macOS: cp

Open .env and put your callsign in it:

APRS_CALLSIGN=W1AW

That is the only file you ever edit by hand. Then start it:

.venv/Scripts/courseops serve                # Linux/macOS: .venv/bin/courseops

It creates its own database on first run - there is no separate setup command - and prints where to go:

  Course Ops
  Setup: http://localhost:8000/setup
         (first run - it will ask you to create an administrator)
         Setup code: 7F3A9C1E
         The form asks for it. Nobody else can create that account.

  Listening on http://127.0.0.1:8000   Ctrl-C to stop

If you get "APRS_CALLSIGN is still the placeholder N0CALL", you copied the file but have not edited it yet - that is the step above.

Then do the rest in a browser

Open http://localhost:8000/setup. The first visit asks you to create a system administrator and for the setup code printed above - so that whoever started the server, not whoever reached the page first, gets that account. After that you sign in.

Everything else is forms: create an organization and an event, upload the organizer's KML, KMZ or GPX and assign each feature by looking at it on a map, name the aid stations and add their What3Words, build the roster, set bib colours, and copy the access links to send out.

Only two things stay in a terminal, because they happen before the page exists: the callsign in .env, and starting the server.

Reaching it from a phone on the same wifi

By default it listens on localhost only, which no other device can reach. To let phones on your network in:

.venv/Scripts/courseops serve --host 0.0.0.0

Then browse to http://<your computer's IP>:8000/... from the phone. Find the IP with ipconfig on Windows or ip addr on Linux. You may have to allow the port through the firewall.

One thing will not work over plain http: the "you are here" dot, and so the SAG queue's "nearest me" ordering. Browsers only allow geolocation in a secure context, and localhost is the sole exception. Everything else works normally. That is the reason for the next section rather than a limitation you can configure away.

Sharing it without a web server

You do not need Apache, a domain or a certificate to let volunteers in. A tunnel gives you a public HTTPS address pointing straight at the app on your own laptop, and it takes one command. HTTPS is not cosmetic here - it is what makes the "where am I" dot and SAG's nearest me ordering work at all, neither of which a plain LAN can do.

Two free options, both fine for a one-day event.

Tailscale - stable address, only your machine installs anything:

tailscale funnel 8000     # public: the address volunteers use
tailscale serve  8000     # your own devices only: for testing on your phone

Use serve while you are setting up and walking the course, funnel on the day. The first tailscale funnel may send you to the admin console to switch Funnel on for your tailnet - do that before race week.

Cloudflare Tunnel - no account at all:

cloudflared tunnel --url http://localhost:8000

Either way, start the app pointed at the address the tunnel gave you:

courseops serve <event> --behind-proxy --base-url https://<tunnel-host>

--behind-proxy keeps the Secure flag on admin session cookies, since the tunnel terminates TLS and speaks plain HTTP to the app. --base-url makes the role links it prints carry the tunnel address instead of localhost - those links are what you are about to send to fifteen people.

Three things to know before choosing:

  • A Cloudflare quick tunnel takes a new hostname every time it starts. If the laptop reboots at mile 6, every link you handed out is dead and you are re-sending URLs while running a net. Tailscale's address is stable.
  • ngrok works, but not on the free tier for this. Free ngrok shows every visitor a click-through warning page first. You will click through once while setting up, get a cookie that hides it for seven days, and never see it again - so it looks fine right up until your volunteers meet it at 6am and conclude the link is broken. A paid plan removes it.
  • A tunnel is a dependency you do not control on race morning. For one event that is usually a fair trade against configuring Apache. For a club running this every year, a real server is sturdier.

docs/DEPLOYMENT.md compares all of them in a table and covers what a tunnel does not change - role links are bearer tokens, so a public tunnel widens the audience from your wifi to anyone holding the URL.

On a real web server

docs/DEPLOYMENT.md walks through it: Apache as a reverse proxy, a Let's Encrypt certificate, and a systemd unit so it comes back after a reboot. Ready made files are in deploy/.

Three things are easy to get wrong, so they are called out there:

  • Apache needs mod_proxy_wstunnel, and the /ws/ rules must come before the catch-all - otherwise the map loads and then never moves, with no error.
  • Run with --behind-proxy or session cookies silently lose the Secure flag, because behind a proxy the app cannot see the real scheme.
  • Bind to 127.0.0.1 so nobody can reach it bypassing TLS.

For one club on one afternoon, a laptop with --host 0.0.0.0 is genuinely enough - you just lose the location dot.

Or set it up from the command line

The CLI does the same things and is better for repeat or scripted setup.

Create an event and a roster:

courseops init-db
courseops add-event marathon2026 "Spring Marathon 2026" \
    --date 2026-04-11 --timezone America/Chicago --lat 34.73 --lon -86.58

# The callsign alone is enough. The SSID belongs to whichever radio or phone
# app they bring on the day, so the app binds the entry to the first SSID it
# hears that looks like a person rather than a digipeater.
courseops add-station marathon2026 N0CALL "Half-back" --category sweep

# Give an SSID yourself only when you know it and want to pin it
courseops add-station marathon2026 N0CALL-7 "Full-back" --category sweep

# Someone assigned but not beaconing — excluded from the APRS-IS filter and
# from staleness alerting, which is typical for aid station operators
courseops add-station marathon2026 KI4HMD-1 "Aid 4" --category aid_station --no-aprs

courseops roster marathon2026     # shows the roster and the generated APRS-IS filter

Import the organizer's course files. Import is additive - the full course, the half course and the water stops usually arrive as separate files:

courseops import m2026 SpringMarathon-Full.kmz
courseops import m2026 half.gpx           # GPX from MapMyRun, Strava, Garmin
courseops review m2026        # lists what was found, with advisory suggestions

GPX is read into the same review: tracks and routes become assignable courses, waypoints become assignable places. A recorded GPX (an actual run rather than a drawn route) can carry thousands of points; it imports intact with a warning, because import never thins a file on its own.

Nothing becomes a course or an aid station until you say so. Organizer KML is reliably messy - placemarks named "Untitled Path", routes split across several segments in arbitrary order and direction, folders mixing water stops with parking - so each feature is assigned by hand:

# Stitch several segments into one course; segments drawn backwards are
# reversed automatically, and gaps are reported rather than hidden
courseops assign-course m2026 1 3 --name "Half" --color "#cc3333"

courseops assign-poi m2026 6 --type aid_station --what3words filled.count.soap
courseops discard m2026 2 8 9

courseops courses m2026       # what you ended up with
courseops set-w3w m2026 4 index.home.raft

Pins on a labelled layer carry one or two characters from the place name, so Aid 3 reads 3 and Water B reads B without opening anything. Which layers are labelled is per event, under Layers; the characters are derived from the name, with an override in the Places table for where that guesses wrong.

What3Words addresses are entered by hand and maintained by Net Control. There is no API integration: it is a paid service, so the app validates the shape of an address but never resolves it. The KML coordinates remain authoritative. The Places table shows them beside each place with a copy button: paste them into the what3words search to read the words off, type the words back, and a link beside the box opens that square on what3words to check it. Tick rows and Export CSV to share the list - layer, name, coordinates, What3Words.

Run the server. It serves the map and the setup application, and pushes positions to every browser over a WebSocket:

courseops serve m2026

The APRS-IS feed is off until you turn it on, under Setup -> Tracking. One connection is opened for the whole server, receive-only, and the switch is remembered across restarts.

Leave it off outside an event. The filter asks for each operator's callsign wherever they are rather than only on the course, so a feed left running records where your volunteers are every day - which is not what anyone agreed to by joining a roster. Turn it on for the event, and for a check-in rehearsal beforehand.

That prints one link per role. Send each to the right group:

  Net Control   http://localhost:8000/e/m2026/KXPbeBeL...
  SAG           http://localhost:8000/e/m2026/Nl0s3QBM...
  Liaison       http://localhost:8000/e/m2026/kKUjMiR_...
  Logistics     http://localhost:8000/e/m2026/9fQ2xLmT...

These are bearer links - anyone holding one has that role, and there is no public view. Permission is per capability rather than one write flag:

Role Can change
Net Control Everything
SAG Report an incident, and work the pickup queue - en route, picked up, dropped off, and the bib
Liaison Report an incident. Embedded with Public Safety and Medics
Logistics Report an incident. Traffic control, cones, teardown
Staff Nothing. Map, stations and lead runners, read-only; never shown pickups or course notes. The link to hand to race staff and the organizer

Every role can report; only NCS and SAG work the queue. All four teams are somewhere an incident can happen, and a report that has to be relayed over the radio to whoever holds the right link is a report that arrives late or not at all. So any link can open a pickup or a course note and fill in the bib and the note. Dispatching, delivering and clearing one stays with NCS and SAG: the queue is read as "who is still waiting", and a link left in a car must not be able to empty it.

Either drop the pin by tapping the map, or press Here to place it at your own location. Tapping is the one that always works - Liaison sits at the EOC and is usually reporting somewhere they have never been - so "Here" is the shortcut beside it and never the default. Your location is only sent when you press that button; the locate dot itself stays in your browser.

SAG is scoped deliberately: a bearer link lives in a moving vehicle, so a lost phone should cost one incident queue rather than the roster and the links. Revoke a leaked link with courseops revoke-link m2026 <id> and issue a fresh one with courseops links m2026 --new liaison.

The map is built for a phone held one-handed outdoors: full-bleed map, panels as bottom sheets, high contrast for daylight, and layer toggles so the field roles can hide the fixed aid station operators. Station markers differ by shape as well as colour, and every station shows how long ago it was last heard - a marker never moves except when a packet actually arrives.

To inspect the feed from the command line instead:

courseops ingest marathon2026 --max-packets 20    # short smoke test
courseops ingest marathon2026                     # run until Ctrl-C
courseops tail marathon2026 --latest              # newest position per station

Categories: net_control, aid_station, sweep, sag, shadow, rover, start_finish.

After the event, Archive on the Events list turns tracking off, deletes the stored volunteer positions, ends the role links and hides the event; Show archived brings it back into view. To keep one event as its own file - no accounts, links or positions - and read it later:

courseops export marathon2026 marathon2026.sqlite3
courseops serve --db marathon2026.sqlite3 --no-ingest --port 8020

Development

.venv/bin/pytest -q

The test suite never touches the network — packet parsing is checked against a fixture corpus in tests/fixtures/packets.txt. When live traffic turns up a packet that parses wrongly, add the line to that file.

Captured live traffic belongs in tests/fixtures/live_*.txt, which is gitignored: it contains the positions of people who did not consent to being in a public repo.

License

MIT

About

Web map for marathon-style events: race courses from KML/KMZ plus live APRS tracking of ham radio operators

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages