Files
wingbits/pkg/client/client.go
T
rmcguire 9e94696363
Publish / release (push) Successful in 1m23s
add string type enums and retry config
2026-06-23 22:35:26 -04:00

202 lines
6.2 KiB
Go

// 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"
)
// RetryConfig controls how transient request failures (transport errors and 5xx
// responses) are retried. The zero value disables retries.
type RetryConfig struct {
// MaxRetries is the number of additional attempts made after the initial
// request. Zero means no retries.
MaxRetries int
// BaseDelay is the wait before the first retry. It doubles on each
// subsequent retry (exponential backoff). Zero means retry immediately.
BaseDelay time.Duration
// MaxDelay caps the per-retry backoff delay. Zero means no cap.
MaxDelay time.Duration
}
// 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
userAgent string
retry RetryConfig
}
// 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 }
}
// WithUserAgent overrides the User-Agent header sent with requests.
func WithUserAgent(ua string) Option {
return func(c *Client) { c.userAgent = ua }
}
// WithRetry enables retries with exponential backoff for transient failures.
func WithRetry(cfg RetryConfig) Option {
return func(c *Client) { c.retry = cfg }
}
// 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{
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}
}