wingbits client and cli helpers for mgw310 and other devices

This commit is contained in:
2026-06-22 22:11:27 -04:00
parent edb6b39543
commit 24fe4258f7
28 changed files with 2980 additions and 2 deletions
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"flag"
"fmt"
"os"
"time"
)
// config holds the parsed command-line options.
type config struct {
cmd string
host string
interval time.Duration
timeout time.Duration
// aircraft filters
withPos bool
emergency bool
minAlt int
maxAlt int
withinNM float64
squawk string
callsign string
}
// parseFlags parses global flags, the command, and then command-specific flags.
// Aircraft filters are only accepted for the aircraft command.
func parseFlags() config {
var cfg config
fs := flag.NewFlagSet("wingbits", flag.ExitOnError)
fs.Usage = usage(fs)
fs.StringVar(&cfg.host, "host", "", "station host or IP (required)")
fs.DurationVar(&cfg.interval, "interval", 0, "if >0, stream on this interval")
fs.DurationVar(&cfg.timeout, "timeout", 10*time.Second, "per-request timeout")
_ = fs.Parse(os.Args[1:])
cfg.cmd = fs.Arg(0)
requireHostAndCmd(fs, cfg)
parseAircraftFlags(&cfg, fs.Args()[1:])
return cfg
}
func requireHostAndCmd(fs *flag.FlagSet, cfg config) {
if cfg.host == "" || cfg.cmd == "" {
fs.Usage()
os.Exit(2)
}
}
// parseAircraftFlags consumes filter flags that follow the command word.
func parseAircraftFlags(cfg *config, args []string) {
fs := flag.NewFlagSet(cfg.cmd, flag.ExitOnError)
fs.BoolVar(&cfg.withPos, "with-pos", false, "[aircraft] only aircraft with a position")
fs.BoolVar(&cfg.emergency, "emergency", false, "[aircraft] only aircraft squawking an emergency")
fs.IntVar(&cfg.minAlt, "min-alt", 0, "[aircraft] minimum barometric altitude (ft)")
fs.IntVar(&cfg.maxAlt, "max-alt", 0, "[aircraft] maximum barometric altitude (ft)")
fs.Float64Var(&cfg.withinNM, "within-nm", 0, "[aircraft] within this range of the receiver (nm)")
fs.StringVar(&cfg.squawk, "squawk", "", "[aircraft] match this Mode A squawk code")
fs.StringVar(&cfg.callsign, "callsign", "", "[aircraft] match this callsign")
_ = fs.Parse(args)
}
func usage(fs *flag.FlagSet) func() {
return func() {
out := fs.Output()
fmt.Fprintf(out, "wingbits — query a Wingbits station\n\n")
fmt.Fprintf(out, "Usage:\n wingbits -host HOST [global flags] COMMAND [command flags]\n\n")
fmt.Fprintf(out, "Commands:\n")
for _, c := range commandDescs {
fmt.Fprintf(out, " %-12s %s\n", c.name, c.desc)
}
fmt.Fprintf(out, "\nGlobal flags:\n")
fs.PrintDefaults()
fmt.Fprintf(out, "\naircraft flags: -with-pos -emergency -min-alt -max-alt -within-nm -squawk -callsign\n")
fmt.Fprintf(out, "\nExamples:\n")
fmt.Fprintf(out, " wingbits -host 192.168.0.127 check\n")
fmt.Fprintf(out, " wingbits -host 192.168.0.127 aircraft -with-pos -min-alt 30000\n")
fmt.Fprintf(out, " wingbits -host 192.168.0.127 -interval 2s stats\n")
fmt.Fprintf(out, " wingbits -host 192.168.0.127 metrics | jq .NATS\n")
}
}
+217
View File
@@ -0,0 +1,217 @@
// Command wingbits is a small flag-driven CLI for one-off queries against a
// Wingbits station. It prints any endpoint as indented JSON (pipe it to jq), and
// can stream on an interval. It is a thin convenience wrapper over pkg/client.
//
// wingbits -host 192.168.0.127 aircraft -with-pos -min-alt 30000
// wingbits -host 192.168.0.127 -interval 2s stats
// wingbits -host 192.168.0.127 metrics | jq .NATS
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"os/signal"
"strings"
"time"
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/client"
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/readsb"
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/wingbits"
)
// query fetches one endpoint's decoded value.
type query func(context.Context) (any, error)
func main() {
cfg := parseFlags()
c, err := client.NewMGW310Client(cfg.host,
client.WithUserAgent("wingbits-cli/1.0"))
if err != nil {
fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
if err := run(ctx, c, cfg); err != nil {
fatal(err)
}
}
func run(ctx context.Context, c *client.Client, cfg config) error {
if cfg.cmd == "check" {
return runChecks(ctx, c, cfg)
}
q, ok := queries(c, buildFilters(cfg))[cfg.cmd]
if !ok {
return fmt.Errorf("unknown command %q; want one of: %s", cfg.cmd, commandNames())
}
if cfg.interval <= 0 {
return printOnce(ctx, cfg.timeout, q)
}
return pollLoop(ctx, cfg, q)
}
// queries maps each command name to its fetch. aircraft applies the filters;
// metrics is reduced to the typed Wingbits feeder view, which is the useful bit.
func queries(c *client.Client, filters []readsb.AircraftFilter) map[string]query {
return map[string]query{
"aircraft": func(ctx context.Context) (any, error) { return c.Aircraft(ctx, filters...) },
"receiver": func(ctx context.Context) (any, error) { return c.Receiver(ctx) },
"stats": func(ctx context.Context) (any, error) { return c.Stats(ctx) },
"outline": func(ctx context.Context) (any, error) { return c.Outline(ctx) },
"diagnostics": func(ctx context.Context) (any, error) { return c.Diagnostics(ctx) },
"status": func(ctx context.Context) (any, error) { return c.Status(ctx) },
"metrics": metricsQuery(c),
}
}
func metricsQuery(c *client.Client) query {
return func(ctx context.Context) (any, error) {
m, err := c.Metrics(ctx)
if err != nil {
return nil, err
}
return m.Wingbits(), nil
}
}
// commandDescs lists every command with a one-line description, in display order.
var commandDescs = []struct{ name, desc string }{
{"aircraft", "tracked aircraft (aircraft.json) — supports filters"},
{"receiver", "receiver location & capabilities (receiver.json)"},
{"stats", "decoder statistics (stats.json)"},
{"outline", "receiver range polygon (outline.json)"},
{"diagnostics", "network diagnostics (/network/diagnostics)"},
{"metrics", "feeder telemetry (/metrics)"},
{"status", "Tailscale status (/tailscale/status)"},
{"check", "probe every endpoint and report reachability"},
}
// checkOrder is the order endpoints are probed by the check command.
var checkOrder = []string{
"aircraft", "receiver", "stats", "outline", "diagnostics", "metrics", "status",
}
const (
greenCheck = "[\033[32m✓\033[0m]"
redCross = "[\033[31m✗\033[0m]"
)
// runChecks probes every endpoint once, printing a pass/fail line each, and
// returns a non-nil error (non-zero exit) if any endpoint failed.
func runChecks(ctx context.Context, c *client.Client, cfg config) error {
qs := queries(c, nil)
var failed int
for _, name := range checkOrder {
if !checkOne(ctx, cfg.timeout, name, qs[name]) {
failed++
}
}
if failed > 0 {
return fmt.Errorf("%d/%d endpoints failed", failed, len(checkOrder))
}
return nil
}
// checkOne probes a single endpoint and prints its status, latency and a short
// content summary. It returns true on success.
func checkOne(ctx context.Context, timeout time.Duration, name string, q query) bool {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
start := time.Now()
v, err := q(ctx)
elapsed := time.Since(start).Round(time.Millisecond)
if err != nil {
fmt.Printf("%s %-12s %7s %v\n", redCross, name, elapsed, err)
return false
}
fmt.Printf("%s %-12s %7s %s\n", greenCheck, name, elapsed, summarize(v))
return true
}
// summarize returns a one-line description of a decoded endpoint result, used to
// show that an endpoint is serving real content and not just 200-ing.
func summarize(v any) string {
switch t := v.(type) {
case *readsb.AircraftReport:
return fmt.Sprintf("%d aircraft", len(t.Aircraft))
case *readsb.Receiver:
return fmt.Sprintf("readsb %s", strings.Fields(t.Version)[0])
case *readsb.Stats:
return fmt.Sprintf("%d msgs, %d with pos", t.Total.Messages, t.AircraftWithPos)
case *readsb.Outline:
return fmt.Sprintf("%d range points", len(t.ActualRange.Last24h.Points))
case *wingbits.Diagnostics:
return fmt.Sprintf("%d interfaces, backend reachable=%v", len(t.Interfaces), t.AllReachable())
case wingbits.WingbitsMetrics:
return fmt.Sprintf("feeder %s, NATS connected=%v", t.Version, t.NATS.Connected)
case *wingbits.TailscaleStatus:
return fmt.Sprintf("tailscale state=%s online=%v", t.State, t.Online)
}
return "ok"
}
// printOnce runs a single query under a timeout and prints the result as JSON.
func printOnce(ctx context.Context, timeout time.Duration, q query) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
v, err := q(ctx)
if err != nil {
return err
}
return printJSON(v)
}
// pollLoop prints a fresh result immediately and then once per interval until
// the context is cancelled (Ctrl-C). Per-poll errors are logged, not fatal.
func pollLoop(ctx context.Context, cfg config, q query) error {
tick := time.NewTicker(cfg.interval)
defer tick.Stop()
for {
if err := printOnce(ctx, cfg.timeout, q); err != nil && ctx.Err() == nil {
fmt.Fprintln(os.Stderr, "wingbits:", err)
}
select {
case <-ctx.Done():
return nil
case <-tick.C:
}
}
}
func printJSON(v any) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(v)
}
func buildFilters(cfg config) []readsb.AircraftFilter {
var f []readsb.AircraftFilter
add := func(ok bool, flt readsb.AircraftFilter) {
if ok {
f = append(f, flt)
}
}
add(cfg.withPos, readsb.WithPosition())
add(cfg.minAlt > 0, readsb.MinAltitude(cfg.minAlt))
add(cfg.maxAlt > 0, readsb.MaxAltitude(cfg.maxAlt))
add(cfg.withinNM > 0, readsb.WithinNM(cfg.withinNM))
add(cfg.squawk != "", readsb.WithSquawk(cfg.squawk))
add(cfg.callsign != "", readsb.WithCallsign(cfg.callsign))
add(cfg.emergency, readsb.InEmergency())
return f
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, "wingbits:", err)
os.Exit(1)
}
func commandNames() string {
names := make([]string, len(commandDescs))
for i, c := range commandDescs {
names[i] = c.name
}
return strings.Join(names, ", ")
}