4 Commits

Author SHA1 Message Date
rmcguire 84d1ab1401 add position filter
Publish / release (push) Successful in 29s
2026-06-26 22:08:28 -04:00
rmcguire 6dd9def324 add gitignore 2026-06-23 23:04:57 -04:00
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
5 changed files with 147 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
wingbits
+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`. - `alt_baro` may be the string `"ground"` instead of a number → `readsb.AltBaro`.
- readsb timestamps are fractional-second Unix floats → `readsb.UnixTime`. - readsb timestamps are fractional-second Unix floats → `readsb.UnixTime`.
- `outline.json` points are `[lat, lon, altFeet]` tuples → `readsb.RangePoint`. - `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
+55
View File
@@ -2,6 +2,7 @@ package readsb
import ( import (
"iter" "iter"
"math"
"slices" "slices"
"strings" "strings"
"time" "time"
@@ -104,11 +105,65 @@ 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 }
} }
// WithinNMOf keeps aircraft whose current position is within nm nautical miles
// of the given point (decimal degrees). Unlike WithinNM, which measures from the
// receiver, this measures from an arbitrary point; aircraft with no position are
// dropped.
func WithinNMOf(lat, lon, nm float64) AircraftFilter {
return func(a *Aircraft) bool {
return a.HasPosition() && haversineNM(lat, lon, a.Lat, a.Lon) <= nm
}
}
// haversineNM returns the great-circle distance in nautical miles between two
// points given in decimal degrees.
func haversineNM(lat1, lon1, lat2, lon2 float64) float64 {
const earthRadiusNM = 3440.065 // mean Earth radius in nautical miles
dLat, dLon := rad(lat2-lat1), rad(lon2-lon1)
h := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(rad(lat1))*math.Cos(rad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2)
return earthRadiusNM * 2 * math.Asin(math.Sqrt(h))
}
func rad(deg float64) float64 { return deg * math.Pi / 180 }
// SeenWithin keeps aircraft heard from within the given duration. // SeenWithin keeps aircraft heard from within the given duration.
func SeenWithin(d time.Duration) AircraftFilter { func SeenWithin(d time.Duration) AircraftFilter {
return func(a *Aircraft) bool { return a.SeenFor() <= d } return func(a *Aircraft) bool { return a.SeenFor() <= d }
+38
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) {
@@ -75,6 +88,31 @@ func TestAircraftFilters(t *testing.T) {
} }
} }
func TestWithinNMOf(t *testing.T) {
// One degree of latitude is ~60 nm; sanity-check the haversine helper.
if d := haversineNM(0, 0, 1, 0); d < 59 || d > 61 {
t.Errorf("haversineNM(1 deg lat) = %.2f nm, want ~60", d)
}
r := decodeFile[AircraftReport](t, "aircraft.json")
var lat, lon float64
for _, a := range r.Aircraft {
if a.HasPosition() {
lat, lon = a.Lat, a.Lon
break
}
}
near := r.Filter(WithinNMOf(lat, lon, 100))
for _, a := range near {
if !a.HasPosition() || haversineNM(lat, lon, a.Lat, a.Lon) > 100 {
t.Errorf("WithinNMOf kept %s at %.1f nm", a.Hex, haversineNM(lat, lon, a.Lat, a.Lon))
}
}
// The point came from a real aircraft, so at least that one must match.
if len(near) == 0 {
t.Error("WithinNMOf returned no aircraft for an in-data point")
}
}
func TestAltBaroGround(t *testing.T) { func TestAltBaroGround(t *testing.T) {
var a AltBaro var a AltBaro
if err := json.Unmarshal([]byte(`"ground"`), &a); err != nil { if err := json.Unmarshal([]byte(`"ground"`), &a); err != nil {