Files
wingbits/README.md
T
rmcguire 26d7a2c0b7
Publish / release (push) Successful in 24s
update README for filters
2026-06-26 22:37:47 -04:00

321 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# wingbits
Typed Go client and decoders for [Wingbits](https://wingbits.com) ADS-B
stations (such as the **MGW310**) and the underlying
[readsb](https://github.com/wiedehopf/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.
```go
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`](https://github.com/wiedehopf/readsb/blob/dev/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 everything** — `context.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
```sh
go get gitea.libretechconsulting.com/rmcguire/wingbits
```
```go
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
```go
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.
```go
// 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.Any(readsb.WithCategory("A5"), readsb.WithType("mlat")),
readsb.Not(readsb.OnGround()),
)
```
| 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())` |
| `Any` | Keeps aircraft matching at least one of the supplied filters (logical OR). | `readsb.Any(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.
```go
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:
```go
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
```go
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`):
```sh
go install gitea.libretechconsulting.com/rmcguire/wingbits/cmd/wingbits@latest
```
Then run it directly:
```sh
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`:
```go
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 timestamps** — `now`, window `start`/`end` are float seconds.
Decoded into `readsb.UnixTime` (embeds `time.Time`).
- **Range tuples** — `outline.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.
```sh
go test ./...
```
## License
See repository.