Files
rmcguire 385ab85305
Publish / release (push) Successful in 27s
refactor filter operands
2026-06-26 23:18:46 -04:00

13 KiB
Raw Permalink Blame History

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 oneshot 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 rewriting the same brittle structs, rediscovering the same quirks ("alt_baro": "ground", fractionalsecond Unix timestamps, [lat, lon, alt] tuples), and handrolling 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 Wingbitsspecific endpoints are reachable through a single Client, which knows that readsb lives under /readsb/ on :8088 and under /data/ on the tar1090 root :8504.
  • Composable aircraft filters — position, altitude, range, squawk, emergency, callsign, category, MLAT, signal strength, and more, AND/ORcomposable.
  • Streaming built in. Any endpoint becomes a Go 1.23 iterator (iter.Seq2[*T, error]) you can range over at a configurable interval.
  • Bring your own everythingcontext.Context on every call, pluggable *http.Client, perendpoint TLS, custom ports/schemes/paths.
  • Zero nonstdlib 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 decodeonly 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 nonstandard 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 tar1090only).

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 into readsb.AltBaro{Feet, OnGround}.
  • Fractional Unix timestampsnow, window start/end are float seconds. Decoded into readsb.UnixTime (embeds time.Time).
  • Range tuplesoutline.json points are [lat, lon, altFeet] arrays. Decoded into readsb.RangePoint.
  • Stale positions — when a position ages out it moves to lastPosition; exposed as *readsb.Position.

Status

Validated endtoend against a live MGW310. Decoders are covered by tests against captured fixtures; the client is covered against an inprocess test server.

go test ./...

License

See repository.