6 Commits

Author SHA1 Message Date
rmcguire 3bef5ea660 add changelog
Publish / release (push) Successful in 25s
2026-06-23 22:51:28 -04:00
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
rmcguire 8ebccfa558 Add TODO items to client
Prepare for implementation of client retry support
2026-06-23 20:57:28 -04:00
rmcguire 3fe9735506 add license
Publish / release (push) Failing after 14m30s
2026-06-22 23:23:24 -04:00
10 changed files with 393 additions and 21 deletions
+8
View File
@@ -62,3 +62,11 @@ with `curl` when fields change; never hand-edit `testdata/` payloads.
- `alt_baro` may be the string `"ground"` instead of a number → `readsb.AltBaro`.
- readsb timestamps are fractional-second Unix floats → `readsb.UnixTime`.
- `outline.json` points are `[lat, lon, altFeet]` tuples → `readsb.RangePoint`.
## Changelog
`CHANGELOG.md` follows [Keep a Changelog](https://keepachangelog.com/) and
SemVer. Record every user-facing change under `## [Unreleased]` as part of the
change itself — don't defer it to release time. When tagging a release, rename
`[Unreleased]` to the new version with the date and add a fresh empty
`[Unreleased]` section plus its compare link at the bottom.
+45
View File
@@ -0,0 +1,45 @@
# Changelog
All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.2.1] - 2026-06-23
### Added
- `MinSpeed`/`MaxSpeed` aircraft filters in `pkg/types/readsb`, selectable
between ground speed and true airspeed via the `SpeedSource` type. `MaxSpeed`
drops aircraft with no speed reading.
## [0.2.0] - 2026-06-23
### Added
- Client retry configuration with exponential backoff (`RetryConfig`,
`WithRetry`). Retries transport errors and 5xx responses; never retries 4xx or
context cancellation.
- String-typed enums for readsb message type and emitter category.
## [0.1.1] - 2026-06-22
### Added
- License.
## [0.1.0] - 2026-06-22
### Added
- Initial release: `pkg/types/readsb` and `pkg/types/wingbits` decode types,
the `pkg/client` HTTP client with one-shot queries and channel/iterator
polling for every endpoint, the `cmd/wingbits` CLI, and a README.
[Unreleased]: https://gitea.libretechconsulting.com/rmcguire/wingbits/compare/v0.2.1...HEAD
[0.2.1]: https://gitea.libretechconsulting.com/rmcguire/wingbits/compare/v0.2.0...v0.2.1
[0.2.0]: https://gitea.libretechconsulting.com/rmcguire/wingbits/compare/v0.1.1...v0.2.0
[0.1.1]: https://gitea.libretechconsulting.com/rmcguire/wingbits/compare/v0.1.0...v0.1.1
[0.1.0]: https://gitea.libretechconsulting.com/rmcguire/wingbits/releases/tag/v0.1.0
+28
View File
@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2026, Ryan McGuire
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+19
View File
@@ -25,6 +25,19 @@ import (
"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
@@ -89,6 +102,7 @@ type Client struct {
tar1090 *Endpoint
http *http.Client
userAgent string
retry RetryConfig
}
// Option customizes a Client.
@@ -122,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) {
+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) {
c := newTestClient(t)
ctx := t.Context()
+68 -1
View File
@@ -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
}
+5 -5
View File
@@ -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.
+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)] }
}
// 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 {
+13
View File
@@ -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) {