Compare commits
7 Commits
v0.2.0
..
385ab85305
| Author | SHA1 | Date | |
|---|---|---|---|
| 385ab85305 | |||
| 4e683e31bd | |||
| 26d7a2c0b7 | |||
| 84d1ab1401 | |||
| 6dd9def324 | |||
| 3bef5ea660 | |||
| 25486b5c8f |
@@ -0,0 +1 @@
|
|||||||
|
wingbits
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-06-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `And` aircraft filter combinator (logical AND) — the value form of the
|
||||||
|
implicit AND that `Filter`/`All` apply to their arguments. Pairs with `Or`/`Not`
|
||||||
|
for nesting, e.g. `Or(And(a, b), And(c, d))`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Renamed the `Any` filter combinator to `Or`, giving a consistent
|
||||||
|
`And`/`Or`/`Not` vocabulary. **Breaking:** callers using `Any` must switch to
|
||||||
|
`Or`.
|
||||||
|
|
||||||
|
## [0.3.2] - 2026-06-26
|
||||||
|
|
||||||
|
- `WithinNMOf` aircraft filter for distance from an arbitrary point.
|
||||||
|
- Add test for WithinNMOf
|
||||||
|
|
||||||
|
## [0.3.1] - 2026-06-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `WithPosition` aircraft filter to keep only aircraft reporting a lat/lon.
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-06-26
|
||||||
|
|
||||||
|
### 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.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.4.0...HEAD
|
||||||
|
[0.4.0]: https://gitea.libretechconsulting.com/rmcguire/wingbits/compare/v0.3.2...v0.4.0
|
||||||
|
[0.3.2]: https://gitea.libretechconsulting.com/rmcguire/wingbits/compare/v0.3.1...v0.3.2
|
||||||
|
[0.3.1]: https://gitea.libretechconsulting.com/rmcguire/wingbits/compare/v0.3.0...v0.3.1
|
||||||
|
[0.3.0]: https://gitea.libretechconsulting.com/rmcguire/wingbits/compare/v0.2.1...v0.3.0
|
||||||
|
[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
|
||||||
@@ -140,15 +140,39 @@ local, _ := c.Aircraft(ctx,
|
|||||||
|
|
||||||
// OR-composition and negation:
|
// OR-composition and negation:
|
||||||
heavies, _ := c.Aircraft(ctx,
|
heavies, _ := c.Aircraft(ctx,
|
||||||
readsb.Any(readsb.WithCategory("A5"), readsb.WithType("mlat")),
|
readsb.Or(readsb.WithCategory("A5"), readsb.WithType("mlat")),
|
||||||
readsb.Not(readsb.OnGround()),
|
readsb.Not(readsb.OnGround()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// And is the value form of the implicit AND, for nesting inside Or:
|
||||||
|
nearby, _ := c.Aircraft(ctx, readsb.Or(
|
||||||
|
readsb.And(readsb.WithCategory("A7"), readsb.WithinNMOf(40.7, -74.0, 5)),
|
||||||
|
readsb.And(readsb.WithCategory("A7"), readsb.WithinNMOf(41.9, -72.7, 5)),
|
||||||
|
))
|
||||||
```
|
```
|
||||||
|
|
||||||
Available filters include `WithPosition`, `WithHex`, `WithCallsign`,
|
| Filter Name | Description | Sample Usage |
|
||||||
`WithSquawk`, `WithCategory`, `WithType`, `InEmergency`, `IsMLAT`,
|
|---|---|---|
|
||||||
`MinAltitude`, `MaxAltitude`, `OnGround`, `WithinNM`, `SeenWithin`, `MinRSSI`,
|
| `WithPosition` | Keeps only aircraft that currently report a lat/lon. | `readsb.WithPosition()` |
|
||||||
plus the `Not` and `Any` combinators.
|
| `WithHex` | Keeps aircraft whose ICAO address matches any of the given values. | `readsb.WithHex("A1B2C3", "D4E5F6")` |
|
||||||
|
| `WithCallsign` | Keeps aircraft whose (trimmed) callsign matches any value. | `readsb.WithCallsign("UAL2350")` |
|
||||||
|
| `WithSquawk` | Keeps aircraft transmitting any of the given Mode A squawk codes. | `readsb.WithSquawk("7700")` |
|
||||||
|
| `WithCategory` | Keeps aircraft of any of the given emitter categories. | `readsb.WithCategory(readsb.CatLarge)` |
|
||||||
|
| `WithType` | Keeps aircraft of any of the given source types. | `readsb.WithType(readsb.TypeMLAT)` |
|
||||||
|
| `InEmergency` | Keeps only aircraft squawking a non-routine emergency/priority code. | `readsb.InEmergency()` |
|
||||||
|
| `IsMLAT` | Keeps only aircraft whose position was derived by multilateration. | `readsb.IsMLAT()` |
|
||||||
|
| `MinAltitude` | Keeps airborne aircraft at or above the given barometric altitude (feet). | `readsb.MinAltitude(30000)` |
|
||||||
|
| `MaxAltitude` | Keeps aircraft at or below the given barometric altitude (feet); on-ground aircraft always pass. | `readsb.MaxAltitude(10000)` |
|
||||||
|
| `OnGround` | Keeps only aircraft reporting an on-ground barometric altitude. | `readsb.OnGround()` |
|
||||||
|
| `MinSpeed` | Keeps aircraft at or above knots, measured by src (GroundSpeed or TrueAirspeed). | `readsb.MinSpeed(200, readsb.GroundSpeed)` |
|
||||||
|
| `MaxSpeed` | Keeps aircraft with a positive reading at or below knots, measured by src; aircraft with no reading are dropped. | `readsb.MaxSpeed(500, readsb.TrueAirspeed)` |
|
||||||
|
| `WithinNM` | Keeps aircraft within the given range (nautical miles) of the receiver. | `readsb.WithinNM(30)` |
|
||||||
|
| `WithinNMOf` | Keeps aircraft whose current position is within nm nautical miles of the given point (decimal degrees). | `readsb.WithinNMOf(40.7, -74.0, 50)` |
|
||||||
|
| `SeenWithin` | Keeps aircraft heard from within the given duration. | `readsb.SeenWithin(5*time.Minute)` |
|
||||||
|
| `MinRSSI` | Keeps aircraft whose average signal strength is at or above dbfs (e.g. -24). | `readsb.MinRSSI(-24)` |
|
||||||
|
| `Not` | Inverts a filter. | `readsb.Not(readsb.OnGround())` |
|
||||||
|
| `And` | Keeps aircraft matching every supplied filter (logical AND). | `readsb.And(f1, f2)` |
|
||||||
|
| `Or` | Keeps aircraft matching at least one of the supplied filters (logical OR). | `readsb.Or(f1, f2)` |
|
||||||
|
|
||||||
### Streaming
|
### Streaming
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -119,13 +174,22 @@ func MinRSSI(dbfs float64) AircraftFilter {
|
|||||||
return func(a *Aircraft) bool { return a.RSSI >= dbfs }
|
return func(a *Aircraft) bool { return a.RSSI >= dbfs }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// And, Or, and Not compose filters as boolean operators. Filter/All already AND
|
||||||
|
// their arguments; And is the value form, letting you nest, e.g.
|
||||||
|
// Or(And(a, b), And(c, d)).
|
||||||
|
|
||||||
// Not inverts a filter.
|
// Not inverts a filter.
|
||||||
func Not(f AircraftFilter) AircraftFilter {
|
func Not(f AircraftFilter) AircraftFilter {
|
||||||
return func(a *Aircraft) bool { return !f(a) }
|
return func(a *Aircraft) bool { return !f(a) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any keeps aircraft matching at least one of the supplied filters (logical OR).
|
// And keeps aircraft matching every supplied filter (logical AND).
|
||||||
func Any(filters ...AircraftFilter) AircraftFilter {
|
func And(filters ...AircraftFilter) AircraftFilter {
|
||||||
|
return func(a *Aircraft) bool { return keep(a, filters) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or keeps aircraft matching at least one of the supplied filters (logical OR).
|
||||||
|
func Or(filters ...AircraftFilter) AircraftFilter {
|
||||||
return func(a *Aircraft) bool {
|
return func(a *Aircraft) bool {
|
||||||
for _, f := range filters {
|
for _, f := range filters {
|
||||||
if f(a) {
|
if f(a) {
|
||||||
|
|||||||
@@ -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,62 @@ 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 TestOr(t *testing.T) {
|
||||||
|
// Two non-overlapping 5 nm circles; Or should keep aircraft in either.
|
||||||
|
r := &AircraftReport{Aircraft: []Aircraft{
|
||||||
|
{Hex: "nearA", Lat: 40.01, Lon: -75.0},
|
||||||
|
{Hex: "nearB", Lat: 41.0, Lon: -76.01},
|
||||||
|
{Hex: "between", Lat: 40.5, Lon: -75.5},
|
||||||
|
{Hex: "nopos"},
|
||||||
|
}}
|
||||||
|
near := r.Filter(Or(WithinNMOf(40, -75, 5), WithinNMOf(41, -76, 5)))
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, a := range near {
|
||||||
|
got[a.Hex] = true
|
||||||
|
}
|
||||||
|
if !got["nearA"] || !got["nearB"] || got["between"] || got["nopos"] || len(got) != 2 {
|
||||||
|
t.Errorf("Or(WithinNMOf...) mismatch, matched: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnd(t *testing.T) {
|
||||||
|
// And matches only aircraft passing every filter; here position AND in-circle.
|
||||||
|
r := &AircraftReport{Aircraft: []Aircraft{
|
||||||
|
{Hex: "in", Lat: 40.01, Lon: -75.0},
|
||||||
|
{Hex: "out", Lat: 50.0, Lon: -75.0},
|
||||||
|
{Hex: "nopos"},
|
||||||
|
}}
|
||||||
|
near := r.Filter(And(WithPosition(), WithinNMOf(40, -75, 5)))
|
||||||
|
if len(near) != 1 || near[0].Hex != "in" {
|
||||||
|
t.Errorf("And mismatch, matched: %+v", near)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user