Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25486b5c8f | |||
| 9e94696363 | |||
| 9b0e05d477 |
+18
-2
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)] }
|
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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user