wingbits client and cli helpers for mgw310 and other devices
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
Reference in New Issue
Block a user