304 lines
10 KiB
Markdown
304 lines
10 KiB
Markdown
# 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 one‑shot 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 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`](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 Wingbits‑specific
|
||
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/OR‑composable.
|
||
- **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`, per‑endpoint TLS, custom ports/schemes/paths.
|
||
- **Zero non‑stdlib 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 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
|
||
|
||
```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()),
|
||
)
|
||
```
|
||
|
||
Available filters include `WithPosition`, `WithHex`, `WithCallsign`,
|
||
`WithSquawk`, `WithCategory`, `WithType`, `InEmergency`, `IsMLAT`,
|
||
`MinAltitude`, `MaxAltitude`, `OnGround`, `WithinNM`, `SeenWithin`, `MinRSSI`,
|
||
plus the `Not` and `Any` combinators.
|
||
|
||
### 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 non‑standard 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 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 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 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.
|
||
|
||
```sh
|
||
go test ./...
|
||
```
|
||
|
||
## License
|
||
|
||
See repository.
|