Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25486b5c8f | |||
| 9e94696363 | |||
| 9b0e05d477 |
+18
-2
@@ -25,8 +25,18 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TODO: Implement client retry configuration
|
||||
// Also support exponential backoff
|
||||
// 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 (
|
||||
@@ -92,6 +102,7 @@ type Client struct {
|
||||
tar1090 *Endpoint
|
||||
http *http.Client
|
||||
userAgent string
|
||||
retry RetryConfig
|
||||
}
|
||||
|
||||
// Option customizes a Client.
|
||||
@@ -125,6 +136,11 @@ 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) {
|
||||
|
||||
@@ -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) {
|
||||
c := newTestClient(t)
|
||||
ctx := t.Context()
|
||||
|
||||
+68
-1
@@ -3,9 +3,11 @@ package client
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/readsb"
|
||||
"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
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Transient failures are retried per the client's RetryConfig.
|
||||
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)
|
||||
if err != nil {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ type Aircraft struct {
|
||||
// indicates a non-ICAO address (TIS-B).
|
||||
Hex string `json:"hex"`
|
||||
// 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 string `json:"flight,omitempty"`
|
||||
// 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 string `json:"squawk,omitempty"`
|
||||
// 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 string `json:"category,omitempty"`
|
||||
Category EmitterCategory `json:"category,omitempty"`
|
||||
|
||||
// Lat/Lon are the last known position in decimal degrees.
|
||||
Lat float64 `json:"lat,omitempty"`
|
||||
@@ -94,7 +94,7 @@ type Aircraft struct {
|
||||
NACP int `json:"nac_p,omitempty"`
|
||||
NACV int `json:"nac_v,omitempty"`
|
||||
SIL int `json:"sil,omitempty"`
|
||||
SILType string `json:"sil_type,omitempty"`
|
||||
SILType SILType `json:"sil_type,omitempty"`
|
||||
GVA int `json:"gva,omitempty"`
|
||||
SDA int `json:"sda,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.
|
||||
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.
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -66,16 +66,16 @@ func WithSquawk(codes ...string) AircraftFilter {
|
||||
return func(a *Aircraft) bool { return set[strings.ToLower(a.Squawk)] }
|
||||
}
|
||||
|
||||
// WithCategory keeps aircraft of any of the given emitter categories (e.g. "A3").
|
||||
func WithCategory(cats ...string) AircraftFilter {
|
||||
set := lowerSet(nil, cats, "")
|
||||
return func(a *Aircraft) bool { return set[strings.ToLower(a.Category)] }
|
||||
// WithCategory keeps aircraft of any of the given emitter categories (e.g. CatLarge).
|
||||
func WithCategory(cats ...EmitterCategory) AircraftFilter {
|
||||
set := lowerSet(nil, strs(cats), "")
|
||||
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").
|
||||
func WithType(types ...string) AircraftFilter {
|
||||
set := lowerSet(nil, types, "")
|
||||
return func(a *Aircraft) bool { return set[strings.ToLower(a.Type)] }
|
||||
// WithType keeps aircraft of any of the given source types (e.g. TypeADSBICAO, TypeMLAT).
|
||||
func WithType(types ...MessageType) AircraftFilter {
|
||||
set := lowerSet(nil, strs(types), "")
|
||||
return func(a *Aircraft) bool { return set[strings.ToLower(string(a.Type))] }
|
||||
}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
|
||||
// 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.
|
||||
func WithinNM(nm float64) AircraftFilter {
|
||||
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
|
||||
// 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 {
|
||||
set := make(map[string]bool, len(values))
|
||||
for _, v := range values {
|
||||
|
||||
@@ -68,6 +68,19 @@ func TestAircraftFilters(t *testing.T) {
|
||||
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.
|
||||
both := r.Filter(WithPosition(), MinAltitude(30000))
|
||||
if len(both) > len(pos) || len(both) > len(high) {
|
||||
|
||||
Reference in New Issue
Block a user