3 Commits

Author SHA1 Message Date
rmcguire 25486b5c8f add speed filters
Publish / release (push) Successful in 31s
2026-06-23 22:47:17 -04:00
rmcguire 9e94696363 add string type enums and retry config
Publish / release (push) Successful in 1m23s
2026-06-23 22:35:26 -04:00
rmcguire 9b0e05d477 Merge pull request 'Add TODO items to client' (#1) from feat-client-retries into main
Reviewed-on: #1
2026-06-24 01:01:55 +00:00
7 changed files with 311 additions and 23 deletions
+18 -2
View File
@@ -25,8 +25,18 @@ import (
"time" "time"
) )
// TODO: Implement client retry configuration // RetryConfig controls how transient request failures (transport errors and 5xx
// Also support exponential backoff // 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. // Default ports exposed by an MGW310 station.
const ( const (
@@ -92,6 +102,7 @@ type Client struct {
tar1090 *Endpoint tar1090 *Endpoint
http *http.Client http *http.Client
userAgent string userAgent string
retry RetryConfig
} }
// Option customizes a Client. // Option customizes a Client.
@@ -125,6 +136,11 @@ func WithUserAgent(ua string) Option {
return func(c *Client) { c.userAgent = ua } 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 // New constructs a Client from options. A Wingbits endpoint with a non-empty
// Host must be supplied via WithWingbitsEndpoint. // Host must be supplied via WithWingbitsEndpoint.
func New(opts ...Option) (*Client, error) { func New(opts ...Option) (*Client, error) {
+55
View File
@@ -144,6 +144,61 @@ func TestClientWingbitsEndpoints(t *testing.T) {
} }
} }
func TestClientRetry(t *testing.T) {
// Fail with 503 for the first two attempts, then succeed; the client must
// retry past the failures and return the eventual body.
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if hits++; hits <= 2 {
http.Error(w, "warming up", http.StatusServiceUnavailable)
return
}
w.Write([]byte(`{}`))
}))
t.Cleanup(srv.Close)
host, port := splitHostPort(t, strings.TrimPrefix(srv.URL, "http://"))
c, err := New(
WithWingbitsEndpoint(Endpoint{Host: host, Port: port}),
WithRetry(RetryConfig{MaxRetries: 3, BaseDelay: time.Millisecond, MaxDelay: 5 * time.Millisecond}),
)
if err != nil {
t.Fatal(err)
}
if _, err := c.Status(context.Background()); err != nil {
t.Fatalf("status after retries: %v", err)
}
if hits != 3 {
t.Fatalf("expected 3 attempts, got %d", hits)
}
}
func TestClientRetryGivesUp(t *testing.T) {
// A 404 is not retryable: the client must fail after a single attempt even
// with retries configured.
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits++
http.Error(w, "nope", http.StatusNotFound)
}))
t.Cleanup(srv.Close)
host, port := splitHostPort(t, strings.TrimPrefix(srv.URL, "http://"))
c, err := New(
WithWingbitsEndpoint(Endpoint{Host: host, Port: port}),
WithRetry(RetryConfig{MaxRetries: 3, BaseDelay: time.Millisecond}),
)
if err != nil {
t.Fatal(err)
}
if _, err := c.Status(context.Background()); err == nil {
t.Fatal("expected error on 404")
}
if hits != 1 {
t.Fatalf("expected 1 attempt for non-retryable status, got %d", hits)
}
}
func TestPollAircraft(t *testing.T) { func TestPollAircraft(t *testing.T) {
c := newTestClient(t) c := newTestClient(t)
ctx := t.Context() ctx := t.Context()
+68 -1
View File
@@ -3,9 +3,11 @@ package client
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"time"
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/readsb" "gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/readsb"
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/wingbits" "gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/wingbits"
@@ -79,8 +81,33 @@ func getJSON[T any](ctx context.Context, c *Client, url string) (*T, error) {
return out, nil return out, nil
} }
// statusError reports a non-200 HTTP status returned by a station.
type statusError struct {
url string
status string
code int
}
func (e *statusError) Error() string {
return fmt.Sprintf("wingbits: GET %s: unexpected status %s", e.url, e.status)
}
// get performs a GET and returns the response body, which the caller must close. // get performs a GET and returns the response body, which the caller must close.
// Transient failures are retried per the client's RetryConfig.
func (c *Client) get(ctx context.Context, url string) (io.ReadCloser, error) { func (c *Client) get(ctx context.Context, url string) (io.ReadCloser, error) {
for attempt := 0; ; attempt++ {
body, err := c.doGet(ctx, url)
if err == nil || attempt >= c.retry.MaxRetries || !retryable(err) {
return body, err
}
if werr := c.waitBackoff(ctx, attempt); werr != nil {
return nil, werr
}
}
}
// doGet performs a single GET attempt.
func (c *Client) doGet(ctx context.Context, url string) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("wingbits: building request: %w", err) return nil, fmt.Errorf("wingbits: building request: %w", err)
@@ -92,7 +119,47 @@ func (c *Client) get(ctx context.Context, url string) (io.ReadCloser, error) {
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
resp.Body.Close() resp.Body.Close()
return nil, fmt.Errorf("wingbits: GET %s: unexpected status %s", url, resp.Status) return nil, &statusError{url: url, status: resp.Status, code: resp.StatusCode}
} }
return resp.Body, nil return resp.Body, nil
} }
// retryable reports whether err is a transient failure worth retrying: any
// transport error, or a 5xx response. Context cancellation is never retried.
func retryable(err error) bool {
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
if se, ok := errors.AsType[*statusError](err); ok {
return se.code >= 500
}
return true
}
// waitBackoff sleeps for the attempt's backoff delay or until ctx is done.
func (c *Client) waitBackoff(ctx context.Context, attempt int) error {
t := time.NewTimer(c.backoff(attempt))
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
// backoff returns the delay before the given retry attempt (0-indexed),
// doubling BaseDelay each attempt and capping at MaxDelay.
func (c *Client) backoff(attempt int) time.Duration {
d := c.retry.BaseDelay
for range attempt {
d *= 2
if c.retry.MaxDelay > 0 && d >= c.retry.MaxDelay {
return c.retry.MaxDelay
}
}
if c.retry.MaxDelay > 0 && d > c.retry.MaxDelay {
return c.retry.MaxDelay
}
return d
}
+12 -12
View File
@@ -32,7 +32,7 @@ type Aircraft struct {
// indicates a non-ICAO address (TIS-B). // indicates a non-ICAO address (TIS-B).
Hex string `json:"hex"` Hex string `json:"hex"`
// Type is the message source/type, e.g. "adsb_icao", "mode_s", "mlat". // Type is the message source/type, e.g. "adsb_icao", "mode_s", "mlat".
Type string `json:"type"` Type MessageType `json:"type"`
// Flight is the callsign. readsb space-pads it; use Callsign for a trimmed value. // Flight is the callsign. readsb space-pads it; use Callsign for a trimmed value.
Flight string `json:"flight,omitempty"` Flight string `json:"flight,omitempty"`
// Registration (r) and aircraft type (t) come from an optional database. // Registration (r) and aircraft type (t) come from an optional database.
@@ -61,9 +61,9 @@ type Aircraft struct {
// Squawk is the Mode A code as 4 octal digits. // Squawk is the Mode A code as 4 octal digits.
Squawk string `json:"squawk,omitempty"` Squawk string `json:"squawk,omitempty"`
// Emergency is the emergency/priority status; "none" when not declared. // Emergency is the emergency/priority status; "none" when not declared.
Emergency string `json:"emergency,omitempty"` Emergency EmergencyStatus `json:"emergency,omitempty"`
// Category is the emitter category, e.g. "A0".."A7", "B0".. (see README-json). // Category is the emitter category, e.g. "A0".."A7", "B0".. (see README-json).
Category string `json:"category,omitempty"` Category EmitterCategory `json:"category,omitempty"`
// Lat/Lon are the last known position in decimal degrees. // Lat/Lon are the last known position in decimal degrees.
Lat float64 `json:"lat,omitempty"` Lat float64 `json:"lat,omitempty"`
@@ -89,14 +89,14 @@ type Aircraft struct {
NavModes []string `json:"nav_modes,omitempty"` NavModes []string `json:"nav_modes,omitempty"`
// ADS-B version and quality/accuracy indicators. // ADS-B version and quality/accuracy indicators.
Version int `json:"version,omitempty"` Version int `json:"version,omitempty"`
NICBaro int `json:"nic_baro,omitempty"` NICBaro int `json:"nic_baro,omitempty"`
NACP int `json:"nac_p,omitempty"` NACP int `json:"nac_p,omitempty"`
NACV int `json:"nac_v,omitempty"` NACV int `json:"nac_v,omitempty"`
SIL int `json:"sil,omitempty"` SIL int `json:"sil,omitempty"`
SILType string `json:"sil_type,omitempty"` SILType SILType `json:"sil_type,omitempty"`
GVA int `json:"gva,omitempty"` GVA int `json:"gva,omitempty"`
SDA int `json:"sda,omitempty"` SDA int `json:"sda,omitempty"`
// Alert and special position identification flags. // Alert and special position identification flags.
Alert int `json:"alert,omitempty"` Alert int `json:"alert,omitempty"`
@@ -133,7 +133,7 @@ func (a Aircraft) HasPosition() bool { return a.Lat != 0 || a.Lon != 0 }
// InEmergency reports whether a non-routine emergency/priority code is set. // InEmergency reports whether a non-routine emergency/priority code is set.
func (a Aircraft) InEmergency() bool { func (a Aircraft) InEmergency() bool {
return a.Emergency != "" && a.Emergency != "none" return a.Emergency != "" && a.Emergency != EmergencyNone
} }
// SeenFor returns the time since the last message was received. // SeenFor returns the time since the last message was received.
+96
View File
@@ -0,0 +1,96 @@
package readsb
// MessageType is the source/type of an aircraft's data (the "type" field). It is
// a named string so callers can compare against typed constants; it decodes from
// and encodes to JSON exactly like a plain string. readsb lists these values in
// descending order of trustworthiness in README-json.md.
type MessageType string
const (
TypeADSBICAO MessageType = "adsb_icao" // Mode S/ADS-B transponder, ICAO address
TypeADSBICAONT MessageType = "adsb_icao_nt" // ADS-B non-transponder emitter, ICAO address
TypeADSRICAO MessageType = "adsr_icao" // ADS-B rebroadcast (e.g. UAT), ICAO address
TypeTISBICAO MessageType = "tisb_icao" // TIS-B about a non-ADS-B target, ICAO address
TypeADSC MessageType = "adsc" // ADS-C via satellite downlink
TypeMLAT MessageType = "mlat" // position from multilateration
TypeOther MessageType = "other" // miscellaneous Basestation/SBS data
TypeModeS MessageType = "mode_s" // Mode S only, no position
TypeADSBOther MessageType = "adsb_other" // ADS-B transponder, non-ICAO address
TypeADSROther MessageType = "adsr_other" // ADS-B rebroadcast, non-ICAO address
TypeTISBOther MessageType = "tisb_other" // TIS-B about a non-ADS-B target, non-ICAO address
TypeTISBTrackfile MessageType = "tisb_trackfile" // TIS-B keyed by track/file id (radar)
)
// EmitterCategory is the ADS-B emitter category (the "category" field): a class
// letter A-D and a digit 0-7. The A0/B0/C0/D0 codes mean "no category info"; the
// remaining D codes and a few others are reserved. Decodes/encodes as a string.
type EmitterCategory string
const (
CatNoInfo EmitterCategory = "A0" // also B0/C0/D0: no category information
CatLight EmitterCategory = "A1" // < 15,500 lb
CatSmall EmitterCategory = "A2" // 15,500-75,000 lb
CatLarge EmitterCategory = "A3" // 75,000-300,000 lb
CatHighVortex EmitterCategory = "A4" // high-vortex large, e.g. B757
CatHeavy EmitterCategory = "A5" // > 300,000 lb
CatHighPerf EmitterCategory = "A6" // > 5g and > 400 kt
CatRotorcraft EmitterCategory = "A7"
CatGlider EmitterCategory = "B1" // glider / sailplane
CatLighterThanAir EmitterCategory = "B2"
CatParachutist EmitterCategory = "B3" // parachutist / skydiver
CatUltralight EmitterCategory = "B4" // ultralight / hang-glider / paraglider
CatUAV EmitterCategory = "B6" // unmanned aerial vehicle
CatSpace EmitterCategory = "B7" // space / transatmospheric vehicle
CatSurfaceEmergency EmitterCategory = "C1" // surface vehicle, emergency
CatSurfaceService EmitterCategory = "C2" // surface vehicle, service
CatPointObstacle EmitterCategory = "C3" // point obstacle (incl. tethered balloons)
CatClusterObstacle EmitterCategory = "C4"
CatLineObstacle EmitterCategory = "C5"
)
// catDescriptions maps every defined emitter code (including reserved ones) to a
// human label. Codes absent from the map are undefined.
var catDescriptions = map[EmitterCategory]string{
"A0": "no information", "A1": "light", "A2": "small", "A3": "large",
"A4": "high-vortex large", "A5": "heavy", "A6": "high performance", "A7": "rotorcraft",
"B0": "no information", "B1": "glider/sailplane", "B2": "lighter-than-air",
"B3": "parachutist", "B4": "ultralight", "B5": "reserved",
"B6": "unmanned aerial vehicle", "B7": "space/transatmospheric",
"C0": "no information", "C1": "surface emergency vehicle", "C2": "surface service vehicle",
"C3": "point obstacle", "C4": "cluster obstacle", "C5": "line obstacle",
"C6": "reserved", "C7": "reserved",
"D0": "no information", "D1": "reserved", "D2": "reserved", "D3": "reserved",
"D4": "reserved", "D5": "reserved", "D6": "reserved", "D7": "reserved",
}
// Description returns the human-readable label for the emitter category, or ""
// if the code is not a defined A0-D7 value.
func (c EmitterCategory) Description() string { return catDescriptions[c] }
// EmergencyStatus is the emergency/priority status (the "emergency" field).
// EmergencyNone means no emergency is declared. Decodes/encodes as a string.
type EmergencyStatus string
const (
EmergencyNone EmergencyStatus = "none"
EmergencyGeneral EmergencyStatus = "general"
EmergencyLifeguard EmergencyStatus = "lifeguard" // medical/lifeguard flight
EmergencyMinFuel EmergencyStatus = "minfuel" // minimum fuel
EmergencyNoRadio EmergencyStatus = "nordo" // no radio communication
EmergencyUnlawful EmergencyStatus = "unlawful" // unlawful interference
EmergencyDowned EmergencyStatus = "downed" // downed aircraft
EmergencyReserved EmergencyStatus = "reserved"
)
// SILType describes how the SIL (source integrity level) probability is scaled
// (the "sil_type" field): per flight hour or per sample. Decodes/encodes as a string.
type SILType string
const (
SILUnknown SILType = "unknown"
SILPerHour SILType = "perhour"
SILPerSample SILType = "persample"
)
+49 -8
View File
@@ -66,16 +66,16 @@ func WithSquawk(codes ...string) AircraftFilter {
return func(a *Aircraft) bool { return set[strings.ToLower(a.Squawk)] } return func(a *Aircraft) bool { return set[strings.ToLower(a.Squawk)] }
} }
// WithCategory keeps aircraft of any of the given emitter categories (e.g. "A3"). // WithCategory keeps aircraft of any of the given emitter categories (e.g. CatLarge).
func WithCategory(cats ...string) AircraftFilter { func WithCategory(cats ...EmitterCategory) AircraftFilter {
set := lowerSet(nil, cats, "") set := lowerSet(nil, strs(cats), "")
return func(a *Aircraft) bool { return set[strings.ToLower(a.Category)] } return func(a *Aircraft) bool { return set[strings.ToLower(string(a.Category))] }
} }
// WithType keeps aircraft of any of the given source types (e.g. "adsb_icao", "mlat"). // WithType keeps aircraft of any of the given source types (e.g. TypeADSBICAO, TypeMLAT).
func WithType(types ...string) AircraftFilter { func WithType(types ...MessageType) AircraftFilter {
set := lowerSet(nil, types, "") set := lowerSet(nil, strs(types), "")
return func(a *Aircraft) bool { return set[strings.ToLower(a.Type)] } return func(a *Aircraft) bool { return set[strings.ToLower(string(a.Type))] }
} }
// InEmergency keeps only aircraft squawking a non-routine emergency/priority code. // InEmergency keeps only aircraft squawking a non-routine emergency/priority code.
@@ -104,6 +104,38 @@ func OnGround() AircraftFilter {
return func(a *Aircraft) bool { return a.AltBaro.OnGround } return func(a *Aircraft) bool { return a.AltBaro.OnGround }
} }
// SpeedSource selects which speed reading a speed filter compares against. Its
// zero value is GroundSpeed.
type SpeedSource int
const (
// GroundSpeed measures against GS (ground speed, knots).
GroundSpeed SpeedSource = iota
// TrueAirspeed measures against TAS (true airspeed, knots).
TrueAirspeed
)
func (s SpeedSource) of(a *Aircraft) float64 {
if s == TrueAirspeed {
return float64(a.TAS)
}
return a.GS
}
// MinSpeed keeps aircraft at or above knots, measured by src.
func MinSpeed(knots float64, src SpeedSource) AircraftFilter {
return func(a *Aircraft) bool { return src.of(a) >= knots }
}
// MaxSpeed keeps aircraft with a positive reading at or below knots, measured by
// src; aircraft with no reading are dropped.
func MaxSpeed(knots float64, src SpeedSource) AircraftFilter {
return func(a *Aircraft) bool {
v := src.of(a)
return v > 0 && v <= knots
}
}
// WithinNM keeps aircraft within the given range (nautical miles) of the receiver. // WithinNM keeps aircraft within the given range (nautical miles) of the receiver.
func WithinNM(nm float64) AircraftFilter { func WithinNM(nm float64) AircraftFilter {
return func(a *Aircraft) bool { return a.RDst > 0 && a.RDst <= nm } return func(a *Aircraft) bool { return a.RDst > 0 && a.RDst <= nm }
@@ -138,6 +170,15 @@ func Any(filters ...AircraftFilter) AircraftFilter {
// lowerSet builds a lowercased lookup set, optionally pre-processing each value // lowerSet builds a lowercased lookup set, optionally pre-processing each value
// with trim(value, cut) when trim is non-nil. // with trim(value, cut) when trim is non-nil.
// strs widens a slice of any string-kind type to []string for the set helpers.
func strs[T ~string](in []T) []string {
out := make([]string, len(in))
for i, v := range in {
out[i] = string(v)
}
return out
}
func lowerSet(trim func(string, string) string, values []string, cut string) map[string]bool { func lowerSet(trim func(string, string) string, values []string, cut string) map[string]bool {
set := make(map[string]bool, len(values)) set := make(map[string]bool, len(values))
for _, v := range values { for _, v := range values {
+13
View File
@@ -68,6 +68,19 @@ func TestAircraftFilters(t *testing.T) {
t.Errorf("WithinNM kept %s at %.1f nm", a.Hex, a.RDst) t.Errorf("WithinNM kept %s at %.1f nm", a.Hex, a.RDst)
} }
} }
fast := r.Filter(MinSpeed(250, GroundSpeed))
for _, a := range fast {
if a.GS < 250 {
t.Errorf("MinSpeed kept %s at %.0f kt", a.Hex, a.GS)
}
}
// MaxSpeed drops aircraft with no reading, so every kept aircraft is positive.
slow := r.Filter(MaxSpeed(250, GroundSpeed))
for _, a := range slow {
if a.GS <= 0 || a.GS > 250 {
t.Errorf("MaxSpeed kept %s at %.0f kt", a.Hex, a.GS)
}
}
// Composition is AND: position AND high altitude is a subset of each. // Composition is AND: position AND high altitude is a subset of each.
both := r.Filter(WithPosition(), MinAltitude(30000)) both := r.Filter(WithPosition(), MinAltitude(30000))
if len(both) > len(pos) || len(both) > len(high) { if len(both) > len(pos) || len(both) > len(high) {