13 KiB
wingbits
Typed Go client and decoders for Wingbits ADS-B stations (such as the MGW310) and the underlying readsb / tar1090 JSON feeds.
Point it at a station and get clean Go structs for the aircraft your antenna is tracking, the receiver's range, decoder statistics, network diagnostics, feeder telemetry, and Tailscale status — as one‑shot queries or live streams over a channel.
c, _ := client.NewMGW310Client("192.168.0.127")
// Every airliner above FL300 that we currently have a position for:
report, _ := c.Aircraft(ctx, readsb.WithPosition(), readsb.MinAltitude(30000))
for _, a := range report.Aircraft {
fmt.Printf("%s %-8s %5dft %3.0fnm\n", a.Hex, a.Callsign(), a.AltBaro.Feet, a.RDst)
}
// a9d59a UAL2350 37000ft 186nm
// ...171 more
Why
A Wingbits station already publishes a rich set of JSON endpoints — but every
project ends up re‑writing the same brittle structs, re‑discovering the same
quirks ("alt_baro": "ground", fractional‑second Unix timestamps, [lat, lon, alt] tuples), and hand‑rolling another polling loop. This module does that once,
correctly, with field documentation drawn straight from
readsb's README-json.md
and validated against real hardware.
- Typed, documented decoders for every endpoint — no
map[string]any. - One client, both roots. The readsb files and the Wingbits‑specific
endpoints are reachable through a single
Client, which knows that readsb lives under/readsb/on:8088and under/data/on the tar1090 root:8504. - Composable aircraft filters — position, altitude, range, squawk, emergency, callsign, category, MLAT, signal strength, and more, AND/OR‑composable.
- Streaming built in. Any endpoint becomes a Go 1.23 iterator
(
iter.Seq2[*T, error]) you canrangeover at a configurable interval. - Bring your own everything —
context.Contexton every call, pluggable*http.Client, per‑endpoint TLS, custom ports/schemes/paths. - Zero non‑stdlib dependencies, including a small Prometheus text parser for
/metrics.
Install
go get gitea.libretechconsulting.com/rmcguire/wingbits
import (
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/client"
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/readsb"
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/wingbits"
)
Packages
| Package | Import | Responsibility |
|---|---|---|
client |
…/wingbits/pkg/client |
HTTP client — query and stream every endpoint |
readsb |
…/wingbits/pkg/types/readsb |
Decode types + filters for aircraft, receiver, stats, outline |
wingbits |
…/wingbits/pkg/types/wingbits |
Decode types for diagnostics, metrics, status |
The types/* packages are decode‑only and have no I/O — useful on their own if
you already have the JSON (from a file, a message bus, a snapshot) and just want
to unmarshal it.
Endpoints at a glance
A station serves two HTTP roots. The client routes each call automatically.
| Method | Source file | Default location |
|---|---|---|
Aircraft |
aircraft.json |
:8504/data/ → falls back to :8088/readsb/ |
Receiver |
receiver.json |
:8504/data/ → :8088/readsb/ |
Stats |
stats.json |
:8504/data/ → :8088/readsb/ |
Outline |
outline.json |
:8504/data/ (tar1090 only) |
Diagnostics |
/network/diagnostics |
:8088 |
Metrics |
/metrics |
:8088 |
Status |
/tailscale/status |
:8088 |
When a tar1090 endpoint is configured it overrides the Wingbits root for the
readsb files; outline.json is served exclusively by tar1090.
Quickstart
package main
import (
"context"
"fmt"
"time"
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/client"
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/readsb"
)
func main() {
// Sensible MGW310 defaults: Wingbits on :8088, tar1090 on :8504.
c, err := client.NewMGW310Client("192.168.0.127")
if err != nil {
panic(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
report, err := c.Aircraft(ctx, readsb.WithPosition())
if err != nil {
panic(err)
}
fmt.Printf("tracking %d aircraft with a position\n", len(report.Aircraft))
}
Filtering aircraft
Filters are plain predicates; pass any number and they compose with AND. They
work both on a fetched report (report.Filter(...)) and inline on the query.
// Anything squawking an emergency, anywhere:
emerg, _ := c.Aircraft(ctx, readsb.InEmergency())
// Low and close: under 10,000 ft and within 30 nm, strong signal:
local, _ := c.Aircraft(ctx,
readsb.WithPosition(),
readsb.MaxAltitude(10000),
readsb.WithinNM(30),
readsb.MinRSSI(-24),
)
// OR-composition and negation:
heavies, _ := c.Aircraft(ctx,
readsb.Or(readsb.WithCategory("A5"), readsb.WithType("mlat")),
readsb.Not(readsb.OnGround()),
)
// And is the value form of the implicit AND, for nesting inside Or:
nearby, _ := c.Aircraft(ctx, readsb.Or(
readsb.And(readsb.WithCategory("A7"), readsb.WithinNMOf(40.7, -74.0, 5)),
readsb.And(readsb.WithCategory("A7"), readsb.WithinNMOf(41.9, -72.7, 5)),
))
| Filter Name | Description | Sample Usage |
|---|---|---|
WithPosition |
Keeps only aircraft that currently report a lat/lon. | readsb.WithPosition() |
WithHex |
Keeps aircraft whose ICAO address matches any of the given values. | readsb.WithHex("A1B2C3", "D4E5F6") |
WithCallsign |
Keeps aircraft whose (trimmed) callsign matches any value. | readsb.WithCallsign("UAL2350") |
WithSquawk |
Keeps aircraft transmitting any of the given Mode A squawk codes. | readsb.WithSquawk("7700") |
WithCategory |
Keeps aircraft of any of the given emitter categories. | readsb.WithCategory(readsb.CatLarge) |
WithType |
Keeps aircraft of any of the given source types. | readsb.WithType(readsb.TypeMLAT) |
InEmergency |
Keeps only aircraft squawking a non-routine emergency/priority code. | readsb.InEmergency() |
IsMLAT |
Keeps only aircraft whose position was derived by multilateration. | readsb.IsMLAT() |
MinAltitude |
Keeps airborne aircraft at or above the given barometric altitude (feet). | readsb.MinAltitude(30000) |
MaxAltitude |
Keeps aircraft at or below the given barometric altitude (feet); on-ground aircraft always pass. | readsb.MaxAltitude(10000) |
OnGround |
Keeps only aircraft reporting an on-ground barometric altitude. | readsb.OnGround() |
MinSpeed |
Keeps aircraft at or above knots, measured by src (GroundSpeed or TrueAirspeed). | readsb.MinSpeed(200, readsb.GroundSpeed) |
MaxSpeed |
Keeps aircraft with a positive reading at or below knots, measured by src; aircraft with no reading are dropped. | readsb.MaxSpeed(500, readsb.TrueAirspeed) |
WithinNM |
Keeps aircraft within the given range (nautical miles) of the receiver. | readsb.WithinNM(30) |
WithinNMOf |
Keeps aircraft whose current position is within nm nautical miles of the given point (decimal degrees). | readsb.WithinNMOf(40.7, -74.0, 50) |
SeenWithin |
Keeps aircraft heard from within the given duration. | readsb.SeenWithin(5*time.Minute) |
MinRSSI |
Keeps aircraft whose average signal strength is at or above dbfs (e.g. -24). | readsb.MinRSSI(-24) |
Not |
Inverts a filter. | readsb.Not(readsb.OnGround()) |
And |
Keeps aircraft matching every supplied filter (logical AND). | readsb.And(f1, f2) |
Or |
Keeps aircraft matching at least one of the supplied filters (logical OR). | readsb.Or(f1, f2) |
Streaming
Every endpoint has a Poll* variant that returns a Go 1.23 iterator
(iter.Seq2[*T, error]). It yields a fresh result immediately, then once per
interval, until the context is cancelled or you break. Each step yields either
a value or the error from that poll.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for report, err := range c.PollAircraft(ctx, 2*time.Second, readsb.WithPosition()) {
if err != nil {
log.Printf("poll failed: %v", err)
continue
}
log.Printf("%d aircraft in view", len(report.Aircraft))
}
Because the iterator runs in your goroutine, breaking the loop stops polling
immediately — there is no background goroutine to leak and no buffer to tune.
Need a channel for fan-out or select? Wrap the iterator at the call site.
The Poll* methods are thin wrappers over the exported generic engine,
client.Poll, so you never re-implement the interval loop — apply the same
cadence to any custom or composed fetch:
for v, err := range client.Poll(ctx, time.Second, func(ctx context.Context) (int, error) {
r, err := c.Aircraft(ctx, readsb.InEmergency())
if err != nil {
return 0, err
}
return len(r.Aircraft), nil
}) {
log.Printf("emergencies: %d (err=%v)", v, err)
}
Filtering exposes an iterator too: report.All(filters...) yields matching
aircraft lazily (handy with break or slices.Collect), while
report.Filter(...) is the eager slice form.
Station health & feeder telemetry
diag, _ := c.Diagnostics(ctx)
fmt.Println("backend reachable:", diag.AllReachable())
m, _ := c.Metrics(ctx)
wb := m.Wingbits() // typed view of the wingbits_* families
fmt.Printf("feeder %s — NATS connected=%v, %0.f msgs sent\n",
wb.Version, wb.NATS.Connected, wb.NATS.Sent)
// Raw access to any Prometheus family is available too:
if v, ok := m.Value("wingbits_beast_received_total"); ok {
fmt.Printf("beast messages received: %.0f\n", v)
}
CLI
A small flag-driven tool lives under cmd/wingbits for one-off queries. It
prints any endpoint as indented JSON (pipe it to jq) and can stream on an
interval.
Install it onto your PATH (lands in $(go env GOPATH)/bin, e.g. ~/go/bin):
go install gitea.libretechconsulting.com/rmcguire/wingbits/cmd/wingbits@latest
Then run it directly:
wingbits -host 192.168.0.127 check
wingbits -host 192.168.0.127 aircraft -with-pos -min-alt 30000
wingbits -host 192.168.0.127 -interval 2s stats
wingbits -host 192.168.0.127 metrics | jq .NATS
Or run it from a checkout without installing, via go run ./cmd/wingbits ….
The check command probes every endpoint once and reports reachability with a
latency and a content summary, exiting non-zero if any fail:
[✓] aircraft 78ms 248 aircraft
[✓] receiver 11ms readsb 3.14.1682
[✓] stats 10ms 91593113 msgs, 230 with pos
[✓] outline 24ms 360 range points
[✓] diagnostics 1.652s 3 interfaces, backend reachable=true
[✓] metrics 24ms feeder v1.12.1, NATS connected=true
[✓] status 12ms state=Stopped online=false
Commands: aircraft, receiver, stats, outline, diagnostics, metrics,
status, check. The aircraft command accepts filter flags (-with-pos,
-emergency, -min-alt, -max-alt, -within-nm, -squawk, -callsign); run
with no command for full usage.
Configuration
NewMGW310Client(host, opts...) wires the standard MGW310 ports and then applies
your options, so you can override anything. For non‑standard deployments build it
explicitly with New:
c, err := client.New(
client.WithWingbitsEndpoint(client.Endpoint{
Scheme: "https", Host: "station.example", Port: 8443,
TLS: &tls.Config{ /* ... */ },
}),
client.WithTar1090Endpoint(client.Endpoint{
Host: "station.example", Port: 8504, DataPath: "data",
}),
client.WithHTTPClient(myHTTPClient), // optional; TLS above is ignored if set
client.WithUserAgent("my-app/1.0"),
)
Omit WithTar1090Endpoint to serve the readsb files from the Wingbits root's
/readsb/ path instead (note: Outline then returns an error, since
outline.json is tar1090‑only).
Handled quirks
These are decoded for you, so you never see them as surprises:
alt_baro: "ground"— barometric altitude is a number or the string"ground". Decoded intoreadsb.AltBaro{Feet, OnGround}.- Fractional Unix timestamps —
now, windowstart/endare float seconds. Decoded intoreadsb.UnixTime(embedstime.Time). - Range tuples —
outline.jsonpoints are[lat, lon, altFeet]arrays. Decoded intoreadsb.RangePoint. - Stale positions — when a position ages out it moves to
lastPosition; exposed as*readsb.Position.
Status
Validated end‑to‑end against a live MGW310. Decoders are covered by tests against captured fixtures; the client is covered against an in‑process test server.
go test ./...
License
See repository.