Audience: contributors building RavenLink from source, packaging custom binaries, or debugging an installed instance in depth.
End users running a packaged binary should start with the README.
cmd/ravenlink/main.go # Entry point + coordinator
cmd/iconbuilder/ # Generates .iconset → .icns for the .app bundle
internal/
├── assets/ # Embedded team logo PNG
├── autostart/ # Launch-on-login (build-tagged per OS)
├── collect/ # Runtime pause flag for NT data collection
├── config/ # YAML config, CLI flags, save-and-restart
├── dashboard/ # Embedded HTTP dashboard + static UI + session list + WPILog export
├── lifecycle/ # Self-restart (exec/spawn), OpenBrowser, OpenFile
├── limelight/ # HTTP poller for Limelight /results (uptime + reachability)
├── ntclient/ # NT4 WebSocket+MessagePack client
├── ntlogger/ # JSONL writing, session lifecycle, match markers
├── obsclient/ # OBS WebSocket (via goobs library)
├── paths/ # OS-standard config + log file paths
├── statemachine/ # Pure-logic state machine (53 tests)
├── status/ # Thread-safe shared state
├── tray/ # Menu bar / system tray icon (fyne.io/systray)
├── typeconv/ # NT value type coercion helpers
├── uploader/ # Store-and-forward upload + JWT auth
└── wpilog/ # WPILog v1.0 encoder (JSONL → .wpilog for AdvantageScope, 22 tests)
third_party/
└── systray/ # Vendored fyne.io/systray (one-line patch)
Requires Go 1.22+.
./scripts/build-macos.sh arm64 # or amd64 or universal
open dist/RavenLink.app # registers with Window ServerImportant: On macOS, running the raw Go binary will not show the menu bar icon. The process needs to be a
.appbundle withLSUIElement=trueinInfo.plistso macOS treats it as a menu-bar-only accessory app (no Dock icon, no ⌘-Tab entry — just the menu bar icon). Thebuild-macos.shscript handles this.
For development, you can still run the binary directly
(./ravenlink --team 1310) — everything works except the menu bar
icon.
go build -o ravenlink ./cmd/ravenlink
./ravenlink --team 1310fyne.io/systray is pure Go on Windows (it uses syscall +
golang.org/x/sys/windows, no CGo). This makes Windows
cross-compilation trivial.
Option A — Cross-compile from macOS/Linux (recommended for dev)
No C toolchain needed. From any platform:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \
go build -ldflags "-H=windowsgui" -o ravenlink.exe ./cmd/ravenlinkThe -H=windowsgui linker flag suppresses the console window so
only the tray icon is visible when the user launches the exe. Copy
ravenlink.exe to the DS laptop and run.
Option B — Cross-compile with CGo via Zig (fallback)
If you ever re-enable a CGo dependency on Windows, install
Zig (brew install zig on macOS),
which ships with a Windows C cross-compiler:
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 \
CC="zig cc -target x86_64-windows-gnu" \
go build -ldflags "-H=windowsgui" -o ravenlink.exe ./cmd/ravenlinkOption C — Build natively on Windows
Install Go, then:
$env:CGO_ENABLED = "0"
go build -ldflags "-H=windowsgui" -o ravenlink.exe ./cmd/ravenlink(If CGo is needed, also install a C toolchain: MSYS2 / MinGW-w64 /
TDM-GCC, and set $env:CGO_ENABLED = "1".)
- Copy
ravenlink.exeandconfig.yamlto a permanent folder (e.g.,C:\FRC\RavenLink\). - Run it once:
C:\FRC\RavenLink\ravenlink.exe --team 1310 - The bridge will:
- Register itself to launch on login
(
HKCU\Software\Microsoft\Windows\CurrentVersion\Run). - Start the web dashboard at
http://localhost:8080. - Show a system tray icon.
- Begin capturing NT data when the robot connects.
- Register itself to launch on login
(
- Turn on the DS laptop — RavenLink starts automatically (system tray icon).
- Open OBS Studio — ensure WebSocket server is enabled.
- Verify via the dashboard:
- NT: Connected (when robot is on)
- OBS: Connected
- The bridge handles everything else — recording, logging, forwarding.
Full config.yaml example (also at config.yaml.example in the
repo root):
bridge:
team: 1310
nt_host: "" # empty = derive 10.TE.AM.2 from team. Set "localhost" for WPILib sim.
obs_host: localhost
obs_port: 4455
obs_password: ""
stop_delay: 10
poll_interval: 0.05
log_level: INFO
record_trigger: fms # fms | auto | any — when to run OBS
collect_trigger: fms # fms | auto | any — when to log/upload NT data
auto_teleop_gap: 5
nt_disconnect_grace: 15
launch_on_login: true
telemetry:
nt_paths:
- /.schema/
- /SmartDashboard/
- /Shuffleboard/
data_dir: ./data
retention_days: 30
# RavenBrain (legacy /login JWT). Enable + set URL to activate.
ravenbrain:
enabled: false
url: "" # empty = disabled regardless of enabled flag
username: telemetry-agent
password: ""
batch_size: 50
upload_interval: 10
# RavenScope (bearer API key). Default URL targets the public hosted
# instance at ravenscope.team1310.ca. Override `url` to point at your
# own RavenScope deployment.
ravenscope:
enabled: true
url: https://ravenscope.team1310.ca
api_key: "" # rsk_live_… bearer token
batch_size: 50
upload_interval: 10
dashboard:
enabled: true
port: 8080
limelight:
enabled: true
last_octets: [11, 12] # 10.TE.AM.<octet> for each camera
poll_interval: 2.0 # seconds between polls
timeout_ms: 1000 # per-request HTTP timeoutEach upload target owns exactly one auth shape:
- API key bearer token (RavenScope
ravenscopesection). Setravenscope.api_keyto anrsk_live_…token. RavenLink sendsAuthorization: Bearer <api_key>directly on every request — no/login, no cache, no renewal. The key itself is the credential. - Legacy username/password (RavenBrain
ravenbrainsection). Setravenbrain.usernameandravenbrain.password. RavenLink callsPOST /loginto exchange them for a short-lived JWT and caches it (auto-renewed 5 minutes before expiry). 401 triggers an invalidate-and-retry.
Both modes refuse to send credentials over plaintext HTTP except
to loopback hosts (localhost, 127.x.x.x, ::1, *.localhost)
so a local dev server or WPILib sim works out of the box. Anything
non-loopback must be https://.
For a WPILib simulator instead of a real robot:
bridge:
nt_host: localhost # overrides the 10.TE.AM.2 derivationOr pass --nt-host localhost on the command line.
For a local RavenScope worker (e.g., wrangler dev):
ravenscope:
enabled: true
url: http://localhost:8787
api_key: rsk_live_…Loopback hosts (localhost, 127.x.x.x, ::1, *.localhost) are
treated as secure — same rule browsers use for "secure contexts".
Any config setting can also be overridden by CLI flag:
./ravenlink \
--ravenscope-url https://ravenscope.team1310.ca \
--ravenscope-api-key rsk_live_… \
--ravenscope-enabled \
--ravenbrain-url https://ravenbrain.team1310.caRun ravenlink --help for the full list.
Per-target upload flow (identical for RavenBrain and RavenScope):
- Authenticate — RavenBrain:
POST /login→ JWT (cached, auto-renewed 5 min before expiry). RavenScope:Authorization: Bearer <api_key>directly, no/login. POST /api/telemetry/session(idempotent upsert — returns existing session if present).GET /api/telemetry/session/{id}→ server'suploadedCountfor resumption.POST /api/telemetry/session/{id}/datain batches, skipping the prefix the server already has.POST /api/telemetry/session/{id}/complete(idempotent on both servers).- Write
<base>.jsonl.<target>.donesidecar marker.
A file moves from data/pending/ to data/uploaded/ only after
every currently enabled target has its marker. Targets that
were enabled previously but are now disabled don't block the move —
the uploader only checks markers for the active set. Zero targets
enabled = local-only mode, files stay in data/pending/.
On 401: invalidate auth, retry once. On network failure: per-target
exponential backoff (5s → 60s). A slow or down target does not
delay uploads to healthy targets. Server-side uploadedCount
guarantees each target's re-attempts are idempotent — no
duplicates, even across process restarts.
The match state machine is pure logic with an injectable clock; 53
unit tests cover every transition. See internal/statemachine/ for
the full state graph and trigger logic.
http://localhost:8080 when the bridge is running:
- Status — live connection status (NT, OBS, plus one row per enabled upload target), match state, telemetry stats, collection state, per-target upload progress.
- Logs — recent slog output (auto-scrolling).
- Sessions — browse all recorded session files (pending +
uploaded), see match IDs for FMS matches, export to
.wpilog, or open directly in AdvantageScope. - Config — edit all settings, save to
config.yaml, hot-reload for supported fields.
The Sessions tab auto-refreshes via SSE when file counts change.
WPILog files saved via "Open" are stored in data/wpilog/ for
quick re-opening.
RavenLink supports three shutdown paths. All three trigger a graceful drain:
- Ctrl-C in the terminal (SIGINT)
- System tray → Quit menu item
kill <pid>orStop-Process -Id <pid>(SIGTERM on Unix; Windows sends the tray a close signal)
On any of these, RavenLink performs a two-phase shutdown:
Phase 1 — stop data collection (instant)
- Main context cancels → all goroutines exit cleanly.
- NT4 client disconnects.
- Logger flushes its bufio buffer, writes a
session_endmarker with entry count, fsyncs, and closes the active JSONL file. - OBS recording is stopped if currently active.
Phase 2 — drain pending uploads (up to 30 seconds)
- Uploader walks
data/pending/sequentially and ships every file to every enabled target as fast as possible, ignoring the normal upload interval and per-target backoff. - A file that gets its markers for all enabled targets moves to
data/uploaded/immediately. - If all files finalize before the 30-second deadline, the process exits cleanly.
- If the deadline hits (slow WiFi, a target is down), files that
aren't fully marked stay in
data/pending/with their partial markers. Next startup resumes — healthy targets skip files they already marked; the unhealthy target retries only what it owes.
Tolerance of ungraceful termination (SIGKILL, power loss,
crash):
- The JSONL file may be missing its
session_endmarker — this is fine.session_endis just another entry in the data stream; the upload protocol doesn't require it. - Data buffered in the
bufio.Writer(up to a few KB) is lost — but the periodic sync ticker flushes to disk every 2 seconds, so the loss is bounded. - On next startup, the uploader finds the unfinished file in
data/pending/and uploads it via the normal flow. The server tracksuploadedCountper session transactionally, so the upload is idempotent and resumable — no duplicate entries. POST /api/telemetry/session/{id}/completeuses the last timestamp in the file asendedAt, which still gives the server a reasonable session boundary even without the explicit marker.
A small companion command for verifying upload-target connectivity:
rbping --target ravenbrain— runs/api/ping→/login→/api/validateagainst the RavenBrain URL from config.rbping --target ravenscope— runs/api/health→ authenticated probe against the RavenScope URL. A 404 on the probe path is the expected success signal for auth.
- RavenLink polls from the machine running it (the DS laptop). Make
sure that laptop is on the robot subnet — a laptop on the venue
WiFi can't reach
10.TE.AM.11. - If you see sporadic
reachable=falseblips, a complex pipeline may be exceeding the 1000 ms timeout. Raiselimelight.timeout_msfurther or check the Limelight's CPU load. - The log shows
limelight: camera went unreachable reason=…on the first failure transition — thereasonfield is the specific error (e.g.dial tcp 10.13.10.11:5807: connect: connection refused,http 404,decode json: …). Sustained failures are silent on purpose; only transitions are logged. - Verify the Limelight's REST server is enabled (it is by default; some reimaging workflows disable it).
- Check the last-octet list actually matches your installation. If
you only have one camera at
.11, setlast_octets: [11].
If a file seems stuck in data/pending/ and only one target is
configured: check for a stray .done marker from a
previously-enabled target. Startup sweeps orphan markers, but a
marker written while that target was enabled persists; once its
target is disabled, the finalize sweep moves the file on the next
tick.
Check logs for tray: onReady fired — if present, the tray IS
installed (and the icon is hidden somewhere — see README
troubleshooting). If missing, the tray goroutine didn't start; the
process is otherwise running.
| Library | Purpose | CGo |
|---|---|---|
github.com/coder/websocket |
WebSocket for NT4 client | No |
github.com/vmihailenco/msgpack/v5 |
NT4 binary frame decoding | No |
github.com/andreykaipov/goobs |
OBS WebSocket v5 (code-generated) | No |
fyne.io/systray |
Cross-platform system tray | macOS only (uses Cocoa); pure Go on Windows/Linux |
gopkg.in/yaml.v3 |
Config file parsing | No |
golang.org/x/sys/windows/registry |
Windows launch-on-login (build-tagged) | No |
Everything else (HTTP server/client, JSON, embed, JWT decode, file I/O) is Go stdlib.
fyne.io/systray is vendored into third_party/systray/ via a
replace directive in go.mod. The only patch is a one-line fix in
systray_darwin.m that positions the popup menu at (0, 0) instead
of (0, button.height + 6) — the upstream coordinate places the
menu above the top of the screen, which forces macOS to clamp it
and show a scroll arrow that hides the first menu item.