wingbits client and cli helpers for mgw310 and other devices

This commit is contained in:
2026-06-22 22:11:27 -04:00
parent edb6b39543
commit 24fe4258f7
28 changed files with 2980 additions and 2 deletions
+194
View File
@@ -0,0 +1,194 @@
// Package client is a client for a Wingbits station (such as the MGW310). It
// exposes the readsb JSON files (aircraft, receiver, stats, outline) and the
// Wingbits-specific endpoints (network diagnostics, Prometheus metrics and
// Tailscale status) as typed Go values, with both one-shot queries and
// channel-based streaming.
//
// Decoded values come from the type-only packages pkg/types/readsb and
// pkg/types/wingbits; this package owns the transport and request logic.
//
// A station serves two HTTP roots:
//
// - The Wingbits root (default :8088) serves the Wingbits endpoints directly
// and the readsb files under readsb/.
// - The tar1090 root (default :8504) serves the readsb files under data/,
// plus data/outline.json. When configured it overrides the Wingbits root
// for all readsb files.
package client
import (
"crypto/tls"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// Default ports exposed by an MGW310 station.
const (
DefaultWingbitsPort = 8088
DefaultTar1090Port = 8504
// defaultDataPath is the tar1090 path under which readsb files are served.
defaultDataPath = "data"
// wingbitsReadsbPath is the path under which the Wingbits root serves readsb files.
wingbitsReadsbPath = "readsb"
)
// Endpoint describes one HTTP root on a station.
type Endpoint struct {
// Scheme is "http" (default) or "https".
Scheme string
// Host is the hostname or IP of the station.
Host string
// Port is the TCP port; 0 selects the role's default.
Port int
// DataPath is the URL path prefix under which readsb files are served. For
// the tar1090 root this defaults to "data"; for the Wingbits root the
// readsb files live under "readsb".
DataPath string
// TLS configures the transport when Scheme is "https". It is only applied
// when the client builds its own http.Client (i.e. no WithHTTPClient).
TLS *tls.Config
}
func (e Endpoint) scheme() string {
if e.Scheme != "" {
return e.Scheme
}
return "http"
}
func (e Endpoint) hostPort(defPort int) string {
port := e.Port
if port == 0 {
port = defPort
}
return fmt.Sprintf("%s:%d", e.Host, port)
}
// url builds an absolute URL for path elements joined under the endpoint,
// optionally prefixed by the endpoint's data path.
func (e Endpoint) url(defPort int, withData bool, elem ...string) string {
parts := elem
if withData && e.DataPath != "" {
parts = append([]string{e.DataPath}, elem...)
}
u := url.URL{
Scheme: e.scheme(),
Host: e.hostPort(defPort),
Path: "/" + strings.Join(parts, "/"),
}
return u.String()
}
// Client talks to a Wingbits station. Construct it with New or NewMGW310Client.
// It is safe for concurrent use.
type Client struct {
wingbits Endpoint
tar1090 *Endpoint
http *http.Client
streamBuf int
userAgent string
}
// Option customizes a Client.
type Option func(*Client)
// WithWingbitsEndpoint sets (and overrides) the Wingbits HTTP root.
func WithWingbitsEndpoint(e Endpoint) Option {
return func(c *Client) { c.wingbits = e }
}
// WithTar1090Endpoint enables a tar1090 HTTP root. When set it overrides the
// Wingbits root for all readsb files (aircraft, receiver, stats) and is the
// only source for outline.json.
func WithTar1090Endpoint(e Endpoint) Option {
return func(c *Client) {
if e.DataPath == "" {
e.DataPath = defaultDataPath
}
c.tar1090 = &e
}
}
// WithHTTPClient supplies a custom http.Client. When provided, per-endpoint TLS
// config is ignored (configure it on the supplied client's transport instead).
func WithHTTPClient(h *http.Client) Option {
return func(c *Client) { c.http = h }
}
// WithStreamBufferSize sets the buffer size of channels returned by the Stream*
// methods. The default is 1.
func WithStreamBufferSize(n int) Option {
return func(c *Client) {
if n >= 0 {
c.streamBuf = n
}
}
}
// WithUserAgent overrides the User-Agent header sent with requests.
func WithUserAgent(ua string) Option {
return func(c *Client) { c.userAgent = ua }
}
// New constructs a Client from options. A Wingbits endpoint with a non-empty
// Host must be supplied via WithWingbitsEndpoint.
func New(opts ...Option) (*Client, error) {
c := &Client{
streamBuf: 1,
userAgent: "wingbits-go/1.0",
}
for _, opt := range opts {
opt(c)
}
if c.wingbits.DataPath == "" {
c.wingbits.DataPath = wingbitsReadsbPath
}
if c.wingbits.Host == "" {
return nil, fmt.Errorf("wingbits: a wingbits endpoint host is required")
}
if c.http == nil {
c.http = buildHTTPClient(c.wingbits, c.tar1090)
}
return c, nil
}
// NewMGW310Client is a convenience constructor for a standard MGW310 reachable
// at host. It wires the Wingbits root to :8088 and a tar1090 root to :8504, then
// applies any options (which may override either endpoint, TLS, the HTTP client,
// and so on).
func NewMGW310Client(host string, opts ...Option) (*Client, error) {
base := []Option{
WithWingbitsEndpoint(Endpoint{Host: host, Port: DefaultWingbitsPort, DataPath: wingbitsReadsbPath}),
WithTar1090Endpoint(Endpoint{Host: host, Port: DefaultTar1090Port, DataPath: defaultDataPath}),
}
return New(append(base, opts...)...)
}
// readsbEndpoint returns the endpoint and default port serving readsb files,
// preferring a configured tar1090 root.
func (c *Client) readsbEndpoint() (Endpoint, int) {
if c.tar1090 != nil {
return *c.tar1090, DefaultTar1090Port
}
return c.wingbits, DefaultWingbitsPort
}
// buildHTTPClient creates a default client, attaching TLS config from whichever
// endpoint supplies one (the tar1090 root takes precedence when both do).
func buildHTTPClient(wb Endpoint, tar *Endpoint) *http.Client {
var tlsCfg *tls.Config
if wb.TLS != nil {
tlsCfg = wb.TLS
}
if tar != nil && tar.TLS != nil {
tlsCfg = tar.TLS
}
tr := &http.Transport{
TLSClientConfig: tlsCfg,
MaxIdleConnsPerHost: 2,
}
return &http.Client{Timeout: 15 * time.Second, Transport: tr}
}
+164
View File
@@ -0,0 +1,164 @@
package client
import (
"context"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"time"
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/readsb"
)
// fixtures map a request path to a captured station response. The readsb files
// and the Wingbits files are reused from their respective type packages' testdata
// so there is a single source of truth for sample payloads.
var fixtures = map[string]string{
"/readsb/aircraft.json": "../types/readsb/testdata/aircraft.json",
"/data/aircraft.json": "../types/readsb/testdata/aircraft.json",
"/data/receiver.json": "../types/readsb/testdata/receiver.json",
"/data/stats.json": "../types/readsb/testdata/stats.json",
"/data/outline.json": "../types/readsb/testdata/outline.json",
"/network/diagnostics": "../types/wingbits/testdata/diagnostics.json",
"/tailscale/status": "../types/wingbits/testdata/status.json",
"/metrics": "../types/wingbits/testdata/metrics.txt",
}
// newTestClient stands up an httptest server that serves the captured station
// fixtures from both the Wingbits (readsb/) and tar1090 (data/) roots.
func newTestClient(t *testing.T) *Client {
t.Helper()
mux := http.NewServeMux()
for path, file := range fixtures {
mux.HandleFunc(path, serveFile(file))
}
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
host, port := splitHostPort(t, strings.TrimPrefix(srv.URL, "http://"))
c, err := New(
WithWingbitsEndpoint(Endpoint{Host: host, Port: port, DataPath: wingbitsReadsbPath}),
WithTar1090Endpoint(Endpoint{Host: host, Port: port, DataPath: defaultDataPath}),
)
if err != nil {
t.Fatal(err)
}
return c
}
func serveFile(file string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
b, err := os.ReadFile(file)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(b)
}
}
func splitHostPort(t *testing.T, hp string) (string, int) {
t.Helper()
h, p, ok := strings.Cut(hp, ":")
if !ok {
t.Fatalf("bad host:port %q", hp)
}
port, err := strconv.Atoi(p)
if err != nil {
t.Fatalf("bad port %q: %v", p, err)
}
return h, port
}
func TestClientReadsbEndpoints(t *testing.T) {
c := newTestClient(t)
ctx := context.Background()
all, err := c.Aircraft(ctx)
if err != nil || len(all.Aircraft) == 0 {
t.Fatalf("aircraft: %v (%d)", err, len(all.Aircraft))
}
// Filters supplied to the query are applied before returning.
pos, err := c.Aircraft(ctx, readsb.WithPosition())
if err != nil {
t.Fatal(err)
}
if len(pos.Aircraft) == 0 || len(pos.Aircraft) > len(all.Aircraft) {
t.Errorf("filtered %d of %d", len(pos.Aircraft), len(all.Aircraft))
}
rc, err := c.Receiver(ctx)
if err != nil || !rc.HasLocation() {
t.Fatalf("receiver: %v", err)
}
if _, err := c.Stats(ctx); err != nil {
t.Fatalf("stats: %v", err)
}
o, err := c.Outline(ctx)
if err != nil || len(o.ActualRange.Last24h.Points) == 0 {
t.Fatalf("outline: %v", err)
}
}
func TestOutlineRequiresTar1090(t *testing.T) {
c, err := New(WithWingbitsEndpoint(Endpoint{Host: "127.0.0.1"}))
if err != nil {
t.Fatal(err)
}
if _, err := c.Outline(context.Background()); err == nil {
t.Error("expected error without tar1090 endpoint")
}
}
func TestClientWingbitsEndpoints(t *testing.T) {
c := newTestClient(t)
ctx := context.Background()
d, err := c.Diagnostics(ctx)
if err != nil || d.Hostname == "" {
t.Fatalf("diagnostics: %v", err)
}
if !d.AllReachable() {
t.Error("expected fixture probes all reachable")
}
s, err := c.Status(ctx)
if err != nil || s.Hostname == "" {
t.Fatalf("status: %v", err)
}
m, err := c.Metrics(ctx)
if err != nil {
t.Fatalf("metrics: %v", err)
}
wb := m.Wingbits()
if wb.Version == "" {
t.Error("expected wingbits_version label")
}
if !wb.NATS.Connected {
t.Error("expected NATS connected in fixture")
}
if wb.Beast.Received == 0 {
t.Error("expected beast received counter")
}
}
func TestStreamAircraft(t *testing.T) {
c := newTestClient(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := c.StreamAircraft(ctx, time.Hour)
select {
case u := <-ch:
if u.Err != nil || u.Value == nil || len(u.Value.Aircraft) == 0 {
t.Fatalf("first update bad: %v", u.Err)
}
case <-time.After(5 * time.Second):
t.Fatal("no first update")
}
cancel()
for range ch { // channel must drain and close after cancellation
}
}
+98
View File
@@ -0,0 +1,98 @@
package client
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/readsb"
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/wingbits"
)
// Aircraft fetches and decodes aircraft.json. When one or more filters are
// supplied, only matching aircraft are retained in the returned report.
func (c *Client) Aircraft(ctx context.Context, filters ...readsb.AircraftFilter) (*readsb.AircraftReport, error) {
ep, port := c.readsbEndpoint()
report, err := getJSON[readsb.AircraftReport](ctx, c, ep.url(port, true, "aircraft.json"))
if err != nil {
return nil, err
}
report.Aircraft = report.Filter(filters...)
return report, nil
}
// Receiver fetches and decodes receiver.json.
func (c *Client) Receiver(ctx context.Context) (*readsb.Receiver, error) {
ep, port := c.readsbEndpoint()
return getJSON[readsb.Receiver](ctx, c, ep.url(port, true, "receiver.json"))
}
// Stats fetches and decodes stats.json.
func (c *Client) Stats(ctx context.Context) (*readsb.Stats, error) {
ep, port := c.readsbEndpoint()
return getJSON[readsb.Stats](ctx, c, ep.url(port, true, "stats.json"))
}
// Outline fetches and decodes outline.json (the receiver range polygon). It is
// only served by the tar1090 root, so a tar1090 endpoint must be configured.
func (c *Client) Outline(ctx context.Context) (*readsb.Outline, error) {
if c.tar1090 == nil {
return nil, fmt.Errorf("wingbits: outline.json requires a tar1090 endpoint")
}
return getJSON[readsb.Outline](ctx, c, c.tar1090.url(DefaultTar1090Port, true, "outline.json"))
}
// Diagnostics fetches and decodes /network/diagnostics from the Wingbits root.
func (c *Client) Diagnostics(ctx context.Context) (*wingbits.Diagnostics, error) {
return getJSON[wingbits.Diagnostics](ctx, c, c.wingbits.url(DefaultWingbitsPort, false, "network", "diagnostics"))
}
// Status fetches and decodes the Tailscale status from the Wingbits root.
func (c *Client) Status(ctx context.Context) (*wingbits.TailscaleStatus, error) {
return getJSON[wingbits.TailscaleStatus](ctx, c, c.wingbits.url(DefaultWingbitsPort, false, "tailscale", "status"))
}
// Metrics fetches /metrics from the Wingbits root and parses the Prometheus
// text exposition format into structured metric families.
func (c *Client) Metrics(ctx context.Context) (*wingbits.Metrics, error) {
body, err := c.get(ctx, c.wingbits.url(DefaultWingbitsPort, false, "metrics"))
if err != nil {
return nil, err
}
defer body.Close()
return wingbits.ParseMetrics(body)
}
// getJSON fetches a URL and decodes its JSON body into a freshly allocated T.
func getJSON[T any](ctx context.Context, c *Client, url string) (*T, error) {
body, err := c.get(ctx, url)
if err != nil {
return nil, err
}
defer body.Close()
out := new(T)
if err := json.NewDecoder(body).Decode(out); err != nil {
return nil, fmt.Errorf("wingbits: decoding %s: %w", url, err)
}
return out, nil
}
// get performs a GET and returns the response body, which the caller must close.
func (c *Client) get(ctx context.Context, url string) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("wingbits: building request: %w", err)
}
req.Header.Set("User-Agent", c.userAgent)
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("wingbits: GET %s: %w", url, err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("wingbits: GET %s: unexpected status %s", url, resp.Status)
}
return resp.Body, nil
}
+85
View File
@@ -0,0 +1,85 @@
package client
import (
"context"
"time"
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/readsb"
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/wingbits"
)
// Update carries one streamed sample of T, or the error from the fetch that
// produced it. Exactly one of Value and Err is meaningful per send.
type Update[T any] struct {
Value *T
Err error
}
// StreamAircraft polls aircraft.json every interval and sends each decoded,
// filtered report on the returned channel until ctx is cancelled. The first
// sample is sent immediately. The channel is closed when ctx ends.
func (c *Client) StreamAircraft(ctx context.Context, interval time.Duration, filters ...readsb.AircraftFilter) <-chan Update[readsb.AircraftReport] {
return stream(ctx, c.streamBuf, interval, func(ctx context.Context) (*readsb.AircraftReport, error) {
return c.Aircraft(ctx, filters...)
})
}
// StreamStats polls stats.json every interval.
func (c *Client) StreamStats(ctx context.Context, interval time.Duration) <-chan Update[readsb.Stats] {
return stream(ctx, c.streamBuf, interval, c.Stats)
}
// StreamReceiver polls receiver.json every interval.
func (c *Client) StreamReceiver(ctx context.Context, interval time.Duration) <-chan Update[readsb.Receiver] {
return stream(ctx, c.streamBuf, interval, c.Receiver)
}
// StreamOutline polls outline.json every interval.
func (c *Client) StreamOutline(ctx context.Context, interval time.Duration) <-chan Update[readsb.Outline] {
return stream(ctx, c.streamBuf, interval, c.Outline)
}
// StreamDiagnostics polls /network/diagnostics every interval.
func (c *Client) StreamDiagnostics(ctx context.Context, interval time.Duration) <-chan Update[wingbits.Diagnostics] {
return stream(ctx, c.streamBuf, interval, c.Diagnostics)
}
// StreamMetrics polls /metrics every interval.
func (c *Client) StreamMetrics(ctx context.Context, interval time.Duration) <-chan Update[wingbits.Metrics] {
return stream(ctx, c.streamBuf, interval, c.Metrics)
}
// StreamStatus polls the Tailscale status every interval.
func (c *Client) StreamStatus(ctx context.Context, interval time.Duration) <-chan Update[wingbits.TailscaleStatus] {
return stream(ctx, c.streamBuf, interval, c.Status)
}
// stream drives a generic poll loop: it calls fetch immediately, then once per
// interval tick, forwarding each result as an Update on a buffered channel.
func stream[T any](ctx context.Context, buf int, interval time.Duration, fetch func(context.Context) (*T, error)) <-chan Update[T] {
ch := make(chan Update[T], buf)
go func() {
defer close(ch)
tick := time.NewTicker(interval)
defer tick.Stop()
for {
send(ctx, ch, fetch)
select {
case <-ctx.Done():
return
case <-tick.C:
}
}
}()
return ch
}
// send runs one fetch and delivers the result, respecting cancellation so a
// full channel cannot wedge the loop past ctx's lifetime.
func send[T any](ctx context.Context, ch chan<- Update[T], fetch func(context.Context) (*T, error)) {
v, err := fetch(ctx)
select {
case ch <- Update[T]{Value: v, Err: err}:
case <-ctx.Done():
}
}