From 769511dec26ea01f93a88018cffb2c0da266978e Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 22 Sep 2026 20:56:01 +0200 Subject: [PATCH] Add Starlink status lines for the physical gateway pane (closes #3) (#7) Detection is a TCP connect to the dish endpoint bound to the physical interface; once a dish answers, get_status is read every 5s and two status lines render under the physical pane, red on disconnect or alert. No dish: nothing drawn, no RPC. Minimal vendored proto bindings for get_status only. Independent review passed (PR 7 comment 100012). Model: opus-4-8 (implementation and review); model: claude-fable-5 (merge) --- README.md | 19 + go.mod | 4 + go.sum | 8 + internal/cli/root.go | 5 + internal/monitor/export_test.go | 45 ++ internal/monitor/monitor.go | 10 + internal/monitor/starlink.go | 166 ++++++ internal/monitor/starlink_test.go | 141 +++++ internal/monitor/ui.go | 5 + internal/starlink/client.go | 129 +++++ internal/starlink/pb/starlink.pb.go | 629 +++++++++++++++++++++++ internal/starlink/pb/starlink.proto | 74 +++ internal/starlink/pb/starlink_grpc.pb.go | 140 +++++ internal/starlink/starlink.go | 81 +++ internal/starlink/starlink_test.go | 79 +++ 15 files changed, 1535 insertions(+) create mode 100644 internal/monitor/starlink.go create mode 100644 internal/monitor/starlink_test.go create mode 100644 internal/starlink/client.go create mode 100644 internal/starlink/pb/starlink.pb.go create mode 100644 internal/starlink/pb/starlink.proto create mode 100644 internal/starlink/pb/starlink_grpc.pb.go create mode 100644 internal/starlink/starlink.go create mode 100644 internal/starlink/starlink_test.go diff --git a/README.md b/README.md index 783b65f..bf24aa7 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,21 @@ Anything outside this matrix — on Linux, only one of the named pair present, o no/multiple default routes when neither is present; on macOS, no default route or more than one physical default route — exits with a clear error. +## Starlink status + +When the non-VPN physical gateway is a Starlink dish, two extra lines appear +under that pane: dish state, uptime, obstruction and alert count on the first, +and pop-ping latency and drop rate with downlink/uplink throughput on the +second. They turn red when the dish is not connected or an alert is active. + +Detection is a TCP connect to the dish's fixed local endpoint, +`192.168.100.1:9200`, made over the physical interface (the same binding the +latency probes use). The dish sits behind the Starlink router, so this is not a +gateway-address check. rtnetmon probes once a minute until a dish answers and +then reads its status every few seconds; when no dish answers, nothing is drawn +and no status is fetched. The status comes from the dish's local `get_status` +gRPC call. + ## Installation ```bash @@ -107,6 +122,10 @@ rtnetmon/ │ │ ├── netdetect.go # selection logic and route parsers (pure) │ │ ├── routes_linux.go # Linux default-route query (build-tagged) │ │ └── routes_darwin.go # macOS default-route query (build-tagged) +│ ├── starlink/ # Starlink dish detection and status +│ │ ├── starlink.go # Status, Client interface, pure Render +│ │ ├── client.go # real gRPC client (get_status) +│ │ └── pb/ # generated bindings for the get_status RPC │ └── monitor/ # core monitoring functionality │ ├── monitor.go # monitor types, probes, logging │ ├── loops.go # monitoring loops (reachability, loss, TCP) diff --git a/go.mod b/go.mod index 135c5bb..b4f8747 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ require ( github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.18.2 golang.org/x/sys v0.29.0 + google.golang.org/grpc v1.67.1 + google.golang.org/protobuf v1.36.6 ) require ( @@ -30,8 +32,10 @@ require ( go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.28.0 // indirect golang.org/x/term v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ea7093a..2312c7a 100644 --- a/go.sum +++ b/go.sum @@ -91,6 +91,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -137,6 +139,12 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/root.go b/internal/cli/root.go index 68d70d4..3a08014 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -98,6 +98,11 @@ func runMonitor(cmd *cobra.Command, cfg *config) error { mon := monitor.NewMonitor(specs, cfg.LogFile) + // The non-VPN physical gateway is the last pane: pane B when there are + // two, the only pane when there is one. That is where a Starlink dish, + // if present upstream, is detected and its status shown. + mon.EnableStarlink(specs[len(specs)-1].Name) + for _, host := range cfg.Hosts { mon.AddReachabilityHost(host) } diff --git a/internal/monitor/export_test.go b/internal/monitor/export_test.go index 5fd3294..f330e5a 100644 --- a/internal/monitor/export_test.go +++ b/internal/monitor/export_test.go @@ -1,7 +1,52 @@ package monitor +import ( + "context" + + "git.eeqj.de/sneak/rtnetmon/internal/starlink" +) + // Test-only accessors exposing unexported state for white-box assertions. +// SetStarlinkClient injects a Starlink client for iface, bypassing the real +// gRPC dialer so the loop can be driven with a fake. +func (m *Monitor) SetStarlinkClient(iface string, c starlink.Client) { + for _, st := range m.interfaces { + if st.Name == iface { + m.slPane = st + m.slState = &starlinkState{} + m.slClient = c + + return + } + } +} + +// StarlinkEnabled reports whether a Starlink client is configured. +func (m *Monitor) StarlinkEnabled() bool { return m.slClient != nil } + +// StarlinkPaneName returns the name of the pane the Starlink lines attach +// to, or "" if none. +func (m *Monitor) StarlinkPaneName() string { + if m.slPane == nil { + return "" + } + + return m.slPane.Name +} + +// StarlinkProbeStep runs one detection step. +func (m *Monitor) StarlinkProbeStep(ctx context.Context) { m.starlinkProbe(ctx) } + +// StarlinkRefreshStep runs one status-refresh step. +func (m *Monitor) StarlinkRefreshStep(ctx context.Context) { m.starlinkRefresh(ctx) } + +// StarlinkSnapshot returns the current Starlink display state: detected, +// have-status, and the latest status. +func (m *Monitor) StarlinkSnapshot() (bool, bool, starlink.Status) { + return m.slState.snapshot() +} + // ReachabilityHosts returns the configured reachability hosts. func (m *Monitor) ReachabilityHosts() []string { return m.reachabilityHosts } diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 83fe1e5..841d759 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -19,6 +19,8 @@ import ( "time" tcell "github.com/gdamore/tcell/v2" + + "git.eeqj.de/sneak/rtnetmon/internal/starlink" ) // Meter glyphs used to render the ASCII loss meter. @@ -90,6 +92,12 @@ type Monitor struct { // Interfaces to monitor (one or two) interfaces []*InterfaceStatus + // Starlink status for the physical (non-VPN gateway) pane. All three + // are nil unless EnableStarlink was called for a monitored interface. + slClient starlink.Client + slState *starlinkState + slPane *InterfaceStatus + // Logging logFile string @@ -208,6 +216,8 @@ func (m *Monitor) Run(ctx context.Context) error { go m.tcpLoop(ctx, st, tcpHosts) } + go m.starlinkLoop(ctx) + m.logf("Starting UI loop") m.uiLoop(ctx) m.logf("UI loop exited, monitor ending") diff --git a/internal/monitor/starlink.go b/internal/monitor/starlink.go new file mode 100644 index 0000000..f25596a --- /dev/null +++ b/internal/monitor/starlink.go @@ -0,0 +1,166 @@ +package monitor + +import ( + "context" + "net" + "sync" + "time" + + tcell "github.com/gdamore/tcell/v2" + + "git.eeqj.de/sneak/rtnetmon/internal/starlink" +) + +// Starlink probe and status cadence: detect once a minute until a dish +// answers, then refresh the status every few seconds. +const ( + starlinkProbePeriod = time.Minute + starlinkStatusPeriod = 5 * time.Second +) + +// starlinkState holds the latest dish status for the physical pane. Nothing +// is drawn until a dish first answers, so an absent dish adds no lines. +type starlinkState struct { + mu sync.RWMutex + detected bool + haveStatus bool + status starlink.Status +} + +// snapshot returns whether a dish was detected, whether a status has been +// read, and the latest status, all under the read lock. +func (s *starlinkState) snapshot() (bool, bool, starlink.Status) { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.detected, s.haveStatus, s.status +} + +// EnableStarlink turns on Starlink detection for the named interface, which +// must be the non-VPN physical gateway pane. It is a no-op if no monitored +// interface has that name. +func (m *Monitor) EnableStarlink(iface string) { + for _, st := range m.interfaces { + if st.Name == iface { + m.slPane = st + m.slState = &starlinkState{} + m.slClient = starlink.NewClient(m.starlinkDialer(iface)) + + return + } + } +} + +// starlinkDialer returns a dialer that binds to iface exactly as the TCP +// latency probes do, so the dish is reached over the physical interface. +func (m *Monitor) starlinkDialer(iface string) starlink.DialFunc { + return func(ctx context.Context, addr string) (net.Conn, error) { + la, err := localAddr(iface) + if err != nil { + return nil, err + } + + d := net.Dialer{LocalAddr: la, Control: bindControl(iface)} + + return d.DialContext(ctx, "tcp", addr) + } +} + +// starlinkLoop probes for a dish and refreshes its status until the context +// is cancelled. It does nothing when Starlink was not enabled. +func (m *Monitor) starlinkLoop(ctx context.Context) { + if m.slClient == nil { + return + } + + m.logf("Starting Starlink monitoring for %s", m.slPane.Name) + m.starlinkProbe(ctx) + + probe := time.NewTicker(starlinkProbePeriod) + status := time.NewTicker(starlinkStatusPeriod) + + defer probe.Stop() + defer status.Stop() + + for { + select { + case <-ctx.Done(): + m.logf("Stopping Starlink monitoring for %s", m.slPane.Name) + + return + case <-probe.C: + m.starlinkProbe(ctx) + case <-status.C: + m.starlinkRefresh(ctx) + } + } +} + +// starlinkProbe detects the dish; once detected it stays detected, and a +// first status is fetched immediately. +func (m *Monitor) starlinkProbe(ctx context.Context) { + if detected, _, _ := m.slState.snapshot(); detected { + return + } + + if !m.slClient.Probe(ctx) { + return + } + + m.slState.mu.Lock() + m.slState.detected = true + m.slState.mu.Unlock() + + m.notifyUI() + m.starlinkRefresh(ctx) +} + +// starlinkRefresh fetches status only once a dish has been detected, so an +// absent dish produces no status traffic. +func (m *Monitor) starlinkRefresh(ctx context.Context) { + if detected, _, _ := m.slState.snapshot(); !detected { + return + } + + st, err := m.slClient.Status(ctx) + if err != nil { + m.logf("Starlink status error: %v", err) + + return + } + + m.slState.mu.Lock() + m.slState.status = st + m.slState.haveStatus = true + m.slState.mu.Unlock() + + m.notifyUI() +} + +// drawStarlink draws the two dish status lines under the physical pane. It +// draws nothing until a dish has answered. +func (m *Monitor) drawStarlink(scr tcell.Screen, y int) int { + detected, haveStatus, st := m.slState.snapshot() + if !detected { + return y + } + + if !haveStatus { + Put(scr, colLeft, y, "Starlink: detected, status unavailable", styleBrightRed()) + + return y + lineStep + } + + line1, line2, alert := starlink.Render(st) + + style := tcell.StyleDefault + if alert { + style = styleBrightRed() + } + + Put(scr, colLeft, y, line1, style) + y += lineStep + Put(scr, colLeft, y, line2, style) + + return y + lineStep +} diff --git a/internal/monitor/starlink_test.go b/internal/monitor/starlink_test.go new file mode 100644 index 0000000..e3f8145 --- /dev/null +++ b/internal/monitor/starlink_test.go @@ -0,0 +1,141 @@ +package monitor_test + +import ( + "context" + "sync" + "testing" + "time" + + "git.eeqj.de/sneak/rtnetmon/internal/monitor" + "git.eeqj.de/sneak/rtnetmon/internal/starlink" +) + +// fakeDish is a Starlink client for tests: no dish, no network. It records +// how often each method is called and returns programmed results. +type fakeDish struct { + mu sync.Mutex + probeOK bool + status starlink.Status + statusErr error + probeCalls int + statusCalls int +} + +func (f *fakeDish) Probe(_ context.Context) bool { + f.mu.Lock() + defer f.mu.Unlock() + + f.probeCalls++ + + return f.probeOK +} + +func (f *fakeDish) Status(_ context.Context) (starlink.Status, error) { + f.mu.Lock() + defer f.mu.Unlock() + + f.statusCalls++ + + return f.status, f.statusErr +} + +func (f *fakeDish) counts() (int, int) { + f.mu.Lock() + defer f.mu.Unlock() + + return f.probeCalls, f.statusCalls +} + +func newTwoPaneMonitor() *monitor.Monitor { + return monitor.NewMonitor([]monitor.IfaceSpec{ + {Name: ifaceTest0, Label: "A"}, + {Name: ifaceTest1, Label: "B"}, + }, "") +} + +func TestEnableStarlinkSelectsPane(t *testing.T) { + t.Parallel() + + mon := newTwoPaneMonitor() + mon.EnableStarlink(ifaceTest1) + + if !mon.StarlinkEnabled() { + t.Fatal("StarlinkEnabled = false, want true") + } + + if got := mon.StarlinkPaneName(); got != ifaceTest1 { + t.Errorf("StarlinkPaneName = %q, want %q", got, ifaceTest1) + } +} + +func TestEnableStarlinkUnknownInterface(t *testing.T) { + t.Parallel() + + mon := newTwoPaneMonitor() + mon.EnableStarlink("does-not-exist") + + if mon.StarlinkEnabled() { + t.Error("StarlinkEnabled = true for an unknown interface, want false") + } +} + +// TestStarlinkNoDishNoStatus verifies that when no dish answers, no status +// is ever fetched and nothing is marked detected. +func TestStarlinkNoDishNoStatus(t *testing.T) { + t.Parallel() + + mon := newTwoPaneMonitor() + fake := &fakeDish{probeOK: false} + mon.SetStarlinkClient(ifaceTest1, fake) + + ctx := context.Background() + mon.StarlinkProbeStep(ctx) + mon.StarlinkRefreshStep(ctx) + + detected, haveStatus, _ := mon.StarlinkSnapshot() + if detected || haveStatus { + t.Errorf("detected=%v haveStatus=%v, want both false", detected, haveStatus) + } + + if _, status := fake.counts(); status != 0 { + t.Errorf("status calls = %d, want 0 (no probing noise)", status) + } +} + +// TestStarlinkDetectedFetchesStatus verifies that once a dish answers, +// detection triggers a status fetch and later refreshes fetch again. +func TestStarlinkDetectedFetchesStatus(t *testing.T) { + t.Parallel() + + mon := newTwoPaneMonitor() + want := starlink.Status{ + State: "CONNECTED", + Uptime: 2 * time.Hour, + DownlinkMbps: 100, + } + fake := &fakeDish{probeOK: true, status: want} + mon.SetStarlinkClient(ifaceTest1, fake) + + ctx := context.Background() + mon.StarlinkProbeStep(ctx) + + detected, haveStatus, got := mon.StarlinkSnapshot() + if !detected || !haveStatus { + t.Fatalf("detected=%v haveStatus=%v, want both true", detected, haveStatus) + } + + if got != want { + t.Errorf("status = %+v, want %+v", got, want) + } + + mon.StarlinkRefreshStep(ctx) + + probe, status := fake.counts() + if probe != 1 { + t.Errorf("probe calls = %d, want 1", probe) + } + + if status != 2 { + t.Errorf("status calls = %d, want 2", status) + } +} diff --git a/internal/monitor/ui.go b/internal/monitor/ui.go index 088bb77..185fcbe 100644 --- a/internal/monitor/ui.go +++ b/internal/monitor/ui.go @@ -107,6 +107,11 @@ func (m *Monitor) DrawInterface(scr tcell.Screen, y, w int, st *InterfaceStatus) y = m.drawTCPTable(scr, y, st, tcpHosts) y = drawICMPStats(scr, y, st) + // The Starlink lines belong to the physical (non-VPN gateway) pane only. + if st == m.slPane { + y = m.drawStarlink(scr, y) + } + return y } diff --git a/internal/starlink/client.go b/internal/starlink/client.go new file mode 100644 index 0000000..2a5eeaa --- /dev/null +++ b/internal/starlink/client.go @@ -0,0 +1,129 @@ +package starlink + +import ( + "context" + "errors" + "fmt" + "net" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + pb "git.eeqj.de/sneak/rtnetmon/internal/starlink/pb" +) + +// errNoDishStatus is returned when the dish answers but the response +// carries no dish status. +var errNoDishStatus = errors.New("no dish status in response") + +// Per-call deadlines so a silent dish never wedges the monitor loop. +const ( + probeTimeout = 2 * time.Second + statusTimeout = 3 * time.Second +) + +// DialFunc opens a TCP connection to addr bound to a chosen interface. The +// monitor supplies one that binds the same way its TCP latency probes do, +// so the dish is reached over the physical (non-VPN) interface rather than +// a VPN tunnel that may hold the default route. +type DialFunc func(ctx context.Context, addr string) (net.Conn, error) + +// GRPCClient is the real Client: it reaches a dish at Address over whatever +// interface dial binds to. +type GRPCClient struct { + dial DialFunc +} + +// NewClient returns a client that talks to a dish at Address, dialing with +// dial so the connection leaves the intended interface. +func NewClient(dial DialFunc) *GRPCClient { + return &GRPCClient{dial: dial} +} + +// Probe connects to the dish's endpoint and closes it. A successful TCP +// connect over the bound interface is the detection signal. +func (c *GRPCClient) Probe(ctx context.Context) bool { + ctx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + + conn, err := c.dial(ctx, Address) + if err != nil { + return false + } + + _ = conn.Close() + + return true +} + +// Status calls the dish's get_status RPC and reduces the response to the +// fields rtnetmon shows. +func (c *GRPCClient) Status(ctx context.Context) (Status, error) { + ctx, cancel := context.WithTimeout(ctx, statusTimeout) + defer cancel() + + // passthrough:/// hands Address straight to our dialer, with no name + // resolution, so the connection is made over the bound interface. + conn, err := grpc.NewClient("passthrough:///"+Address, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) { + return c.dial(ctx, addr) + }), + ) + if err != nil { + return Status{}, fmt.Errorf("starlink dial: %w", err) + } + defer func() { _ = conn.Close() }() + + resp, err := pb.NewDeviceClient(conn).Handle(ctx, &pb.Request{ + Request: &pb.Request_GetStatus{GetStatus: &pb.GetStatusRequest{}}, + }) + if err != nil { + return Status{}, fmt.Errorf("starlink get_status: %w", err) + } + + dish := resp.GetDishGetStatus() + if dish == nil { + return Status{}, errNoDishStatus + } + + return statusFromDish(dish), nil +} + +// statusFromDish reduces the dish's status message to a Status. The +// generated getters are nil-safe, so absent sub-messages read as zero. +func statusFromDish(d *pb.DishGetStatusResponse) Status { + obstruction := float64(d.GetObstructionStats().GetFractionObstructed()) + //nolint:gosec // G115: dish uptime in seconds never approaches int64 max + uptime := time.Duration(d.GetDeviceState().GetUptimeS()) * time.Second + + return Status{ + State: d.GetState().String(), + Uptime: uptime, + ObstructionPct: obstruction * percentScale, + Alerts: countAlerts(d.GetAlerts()), + PopPingMs: float64(d.GetPopPingLatencyMs()), + PopPingDropPct: float64(d.GetPopPingDropRate()) * percentScale, + DownlinkMbps: float64(d.GetDownlinkThroughputBps()) / bitsPerMegabit, + UplinkMbps: float64(d.GetUplinkThroughputBps()) / bitsPerMegabit, + } +} + +// countAlerts counts the active dish alert flags. +func countAlerts(a *pb.DishAlerts) int { + n := 0 + + for _, on := range []bool{ + a.GetMotorsStuck(), + a.GetThermalShutdown(), + a.GetThermalThrottle(), + a.GetUnexpectedLocation(), + } { + if on { + n++ + } + } + + return n +} diff --git a/internal/starlink/pb/starlink.pb.go b/internal/starlink/pb/starlink.pb.go new file mode 100644 index 0000000..b711a7f --- /dev/null +++ b/internal/starlink/pb/starlink.pb.go @@ -0,0 +1,629 @@ +// A minimal slice of the Starlink dish's local gRPC API: just the one +// unary RPC (SpaceX.API.Device.Device/Handle) and the get_status request +// and dish status response, reduced to the handful of fields rtnetmon +// shows. The proto package name and field numbers must match the dish +// exactly for the wire format to line up; every other field the dish sends +// is ignored as an unknown field. +// +// Field numbers, message names and the DishState enum are transcribed from +// the community-maintained pre-generated bindings at +// github.com/starlink-community/starlink-grpc-go, commit +// 2e89f3d7e3092adecbcc04e9e003e61ed5f7c6a3 (get_status = 1004, +// dish_get_status = 2004). They are not verified against a live dish here. +// +// Regenerate with: +// protoc --go_out=. --go_opt=paths=source_relative \ +// --go-grpc_out=. --go-grpc_opt=paths=source_relative \ +// internal/starlink/pb/starlink.proto + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.6 +// protoc v7.36.2 +// source: internal/starlink/pb/starlink.proto + +package starlinkpb + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type DishState int32 + +const ( + DishState_UNKNOWN DishState = 0 + DishState_CONNECTED DishState = 1 + DishState_SEARCHING DishState = 2 + DishState_BOOTING DishState = 3 +) + +// Enum value maps for DishState. +var ( + DishState_name = map[int32]string{ + 0: "UNKNOWN", + 1: "CONNECTED", + 2: "SEARCHING", + 3: "BOOTING", + } + DishState_value = map[string]int32{ + "UNKNOWN": 0, + "CONNECTED": 1, + "SEARCHING": 2, + "BOOTING": 3, + } +) + +func (x DishState) Enum() *DishState { + p := new(DishState) + *p = x + return p +} + +func (x DishState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (DishState) Descriptor() protoreflect.EnumDescriptor { + return file_internal_starlink_pb_starlink_proto_enumTypes[0].Descriptor() +} + +func (DishState) Type() protoreflect.EnumType { + return &file_internal_starlink_pb_starlink_proto_enumTypes[0] +} + +func (x DishState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use DishState.Descriptor instead. +func (DishState) EnumDescriptor() ([]byte, []int) { + return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{0} +} + +type Request struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Request: + // + // *Request_GetStatus + Request isRequest_Request `protobuf_oneof:"request"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Request) Reset() { + *x = Request{} + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Request) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Request) ProtoMessage() {} + +func (x *Request) ProtoReflect() protoreflect.Message { + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Request.ProtoReflect.Descriptor instead. +func (*Request) Descriptor() ([]byte, []int) { + return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{0} +} + +func (x *Request) GetRequest() isRequest_Request { + if x != nil { + return x.Request + } + return nil +} + +func (x *Request) GetGetStatus() *GetStatusRequest { + if x != nil { + if x, ok := x.Request.(*Request_GetStatus); ok { + return x.GetStatus + } + } + return nil +} + +type isRequest_Request interface { + isRequest_Request() +} + +type Request_GetStatus struct { + GetStatus *GetStatusRequest `protobuf:"bytes,1004,opt,name=get_status,json=getStatus,proto3,oneof"` +} + +func (*Request_GetStatus) isRequest_Request() {} + +type GetStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetStatusRequest) Reset() { + *x = GetStatusRequest{} + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetStatusRequest) ProtoMessage() {} + +func (x *GetStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetStatusRequest.ProtoReflect.Descriptor instead. +func (*GetStatusRequest) Descriptor() ([]byte, []int) { + return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{1} +} + +type Response struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Response: + // + // *Response_DishGetStatus + Response isResponse_Response `protobuf_oneof:"response"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Response) Reset() { + *x = Response{} + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Response) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Response) ProtoMessage() {} + +func (x *Response) ProtoReflect() protoreflect.Message { + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Response.ProtoReflect.Descriptor instead. +func (*Response) Descriptor() ([]byte, []int) { + return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{2} +} + +func (x *Response) GetResponse() isResponse_Response { + if x != nil { + return x.Response + } + return nil +} + +func (x *Response) GetDishGetStatus() *DishGetStatusResponse { + if x != nil { + if x, ok := x.Response.(*Response_DishGetStatus); ok { + return x.DishGetStatus + } + } + return nil +} + +type isResponse_Response interface { + isResponse_Response() +} + +type Response_DishGetStatus struct { + DishGetStatus *DishGetStatusResponse `protobuf:"bytes,2004,opt,name=dish_get_status,json=dishGetStatus,proto3,oneof"` +} + +func (*Response_DishGetStatus) isResponse_Response() {} + +type DishGetStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + DeviceState *DeviceState `protobuf:"bytes,2,opt,name=device_state,json=deviceState,proto3" json:"device_state,omitempty"` + ObstructionStats *DishObstructionStats `protobuf:"bytes,1004,opt,name=obstruction_stats,json=obstructionStats,proto3" json:"obstruction_stats,omitempty"` + Alerts *DishAlerts `protobuf:"bytes,1005,opt,name=alerts,proto3" json:"alerts,omitempty"` + State DishState `protobuf:"varint,1006,opt,name=state,proto3,enum=SpaceX.API.Device.DishState" json:"state,omitempty"` + PopPingDropRate float32 `protobuf:"fixed32,1003,opt,name=pop_ping_drop_rate,json=popPingDropRate,proto3" json:"pop_ping_drop_rate,omitempty"` + DownlinkThroughputBps float32 `protobuf:"fixed32,1007,opt,name=downlink_throughput_bps,json=downlinkThroughputBps,proto3" json:"downlink_throughput_bps,omitempty"` + UplinkThroughputBps float32 `protobuf:"fixed32,1008,opt,name=uplink_throughput_bps,json=uplinkThroughputBps,proto3" json:"uplink_throughput_bps,omitempty"` + PopPingLatencyMs float32 `protobuf:"fixed32,1009,opt,name=pop_ping_latency_ms,json=popPingLatencyMs,proto3" json:"pop_ping_latency_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DishGetStatusResponse) Reset() { + *x = DishGetStatusResponse{} + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DishGetStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DishGetStatusResponse) ProtoMessage() {} + +func (x *DishGetStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DishGetStatusResponse.ProtoReflect.Descriptor instead. +func (*DishGetStatusResponse) Descriptor() ([]byte, []int) { + return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{3} +} + +func (x *DishGetStatusResponse) GetDeviceState() *DeviceState { + if x != nil { + return x.DeviceState + } + return nil +} + +func (x *DishGetStatusResponse) GetObstructionStats() *DishObstructionStats { + if x != nil { + return x.ObstructionStats + } + return nil +} + +func (x *DishGetStatusResponse) GetAlerts() *DishAlerts { + if x != nil { + return x.Alerts + } + return nil +} + +func (x *DishGetStatusResponse) GetState() DishState { + if x != nil { + return x.State + } + return DishState_UNKNOWN +} + +func (x *DishGetStatusResponse) GetPopPingDropRate() float32 { + if x != nil { + return x.PopPingDropRate + } + return 0 +} + +func (x *DishGetStatusResponse) GetDownlinkThroughputBps() float32 { + if x != nil { + return x.DownlinkThroughputBps + } + return 0 +} + +func (x *DishGetStatusResponse) GetUplinkThroughputBps() float32 { + if x != nil { + return x.UplinkThroughputBps + } + return 0 +} + +func (x *DishGetStatusResponse) GetPopPingLatencyMs() float32 { + if x != nil { + return x.PopPingLatencyMs + } + return 0 +} + +type DeviceState struct { + state protoimpl.MessageState `protogen:"open.v1"` + UptimeS uint64 `protobuf:"varint,1,opt,name=uptime_s,json=uptimeS,proto3" json:"uptime_s,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeviceState) Reset() { + *x = DeviceState{} + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeviceState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeviceState) ProtoMessage() {} + +func (x *DeviceState) ProtoReflect() protoreflect.Message { + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeviceState.ProtoReflect.Descriptor instead. +func (*DeviceState) Descriptor() ([]byte, []int) { + return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{4} +} + +func (x *DeviceState) GetUptimeS() uint64 { + if x != nil { + return x.UptimeS + } + return 0 +} + +type DishObstructionStats struct { + state protoimpl.MessageState `protogen:"open.v1"` + FractionObstructed float32 `protobuf:"fixed32,1,opt,name=fraction_obstructed,json=fractionObstructed,proto3" json:"fraction_obstructed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DishObstructionStats) Reset() { + *x = DishObstructionStats{} + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DishObstructionStats) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DishObstructionStats) ProtoMessage() {} + +func (x *DishObstructionStats) ProtoReflect() protoreflect.Message { + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DishObstructionStats.ProtoReflect.Descriptor instead. +func (*DishObstructionStats) Descriptor() ([]byte, []int) { + return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{5} +} + +func (x *DishObstructionStats) GetFractionObstructed() float32 { + if x != nil { + return x.FractionObstructed + } + return 0 +} + +type DishAlerts struct { + state protoimpl.MessageState `protogen:"open.v1"` + MotorsStuck bool `protobuf:"varint,1,opt,name=motors_stuck,json=motorsStuck,proto3" json:"motors_stuck,omitempty"` + ThermalShutdown bool `protobuf:"varint,2,opt,name=thermal_shutdown,json=thermalShutdown,proto3" json:"thermal_shutdown,omitempty"` + ThermalThrottle bool `protobuf:"varint,3,opt,name=thermal_throttle,json=thermalThrottle,proto3" json:"thermal_throttle,omitempty"` + UnexpectedLocation bool `protobuf:"varint,4,opt,name=unexpected_location,json=unexpectedLocation,proto3" json:"unexpected_location,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DishAlerts) Reset() { + *x = DishAlerts{} + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DishAlerts) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DishAlerts) ProtoMessage() {} + +func (x *DishAlerts) ProtoReflect() protoreflect.Message { + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DishAlerts.ProtoReflect.Descriptor instead. +func (*DishAlerts) Descriptor() ([]byte, []int) { + return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{6} +} + +func (x *DishAlerts) GetMotorsStuck() bool { + if x != nil { + return x.MotorsStuck + } + return false +} + +func (x *DishAlerts) GetThermalShutdown() bool { + if x != nil { + return x.ThermalShutdown + } + return false +} + +func (x *DishAlerts) GetThermalThrottle() bool { + if x != nil { + return x.ThermalThrottle + } + return false +} + +func (x *DishAlerts) GetUnexpectedLocation() bool { + if x != nil { + return x.UnexpectedLocation + } + return false +} + +var File_internal_starlink_pb_starlink_proto protoreflect.FileDescriptor + +const file_internal_starlink_pb_starlink_proto_rawDesc = "" + + "\n" + + "#internal/starlink/pb/starlink.proto\x12\x11SpaceX.API.Device\"[\n" + + "\aRequest\x12E\n" + + "\n" + + "get_status\x18\xec\a \x01(\v2#.SpaceX.API.Device.GetStatusRequestH\x00R\tgetStatusB\t\n" + + "\arequest\"\x12\n" + + "\x10GetStatusRequest\"k\n" + + "\bResponse\x12S\n" + + "\x0fdish_get_status\x18\xd4\x0f \x01(\v2(.SpaceX.API.Device.DishGetStatusResponseH\x00R\rdishGetStatusB\n" + + "\n" + + "\bresponse\"\xea\x03\n" + + "\x15DishGetStatusResponse\x12A\n" + + "\fdevice_state\x18\x02 \x01(\v2\x1e.SpaceX.API.Device.DeviceStateR\vdeviceState\x12U\n" + + "\x11obstruction_stats\x18\xec\a \x01(\v2'.SpaceX.API.Device.DishObstructionStatsR\x10obstructionStats\x126\n" + + "\x06alerts\x18\xed\a \x01(\v2\x1d.SpaceX.API.Device.DishAlertsR\x06alerts\x123\n" + + "\x05state\x18\xee\a \x01(\x0e2\x1c.SpaceX.API.Device.DishStateR\x05state\x12,\n" + + "\x12pop_ping_drop_rate\x18\xeb\a \x01(\x02R\x0fpopPingDropRate\x127\n" + + "\x17downlink_throughput_bps\x18\xef\a \x01(\x02R\x15downlinkThroughputBps\x123\n" + + "\x15uplink_throughput_bps\x18\xf0\a \x01(\x02R\x13uplinkThroughputBps\x12.\n" + + "\x13pop_ping_latency_ms\x18\xf1\a \x01(\x02R\x10popPingLatencyMs\"(\n" + + "\vDeviceState\x12\x19\n" + + "\buptime_s\x18\x01 \x01(\x04R\auptimeS\"G\n" + + "\x14DishObstructionStats\x12/\n" + + "\x13fraction_obstructed\x18\x01 \x01(\x02R\x12fractionObstructed\"\xb6\x01\n" + + "\n" + + "DishAlerts\x12!\n" + + "\fmotors_stuck\x18\x01 \x01(\bR\vmotorsStuck\x12)\n" + + "\x10thermal_shutdown\x18\x02 \x01(\bR\x0fthermalShutdown\x12)\n" + + "\x10thermal_throttle\x18\x03 \x01(\bR\x0fthermalThrottle\x12/\n" + + "\x13unexpected_location\x18\x04 \x01(\bR\x12unexpectedLocation*C\n" + + "\tDishState\x12\v\n" + + "\aUNKNOWN\x10\x00\x12\r\n" + + "\tCONNECTED\x10\x01\x12\r\n" + + "\tSEARCHING\x10\x02\x12\v\n" + + "\aBOOTING\x10\x032K\n" + + "\x06Device\x12A\n" + + "\x06Handle\x12\x1a.SpaceX.API.Device.Request\x1a\x1b.SpaceX.API.Device.ResponseB SpaceX.API.Device.GetStatusRequest + 4, // 1: SpaceX.API.Device.Response.dish_get_status:type_name -> SpaceX.API.Device.DishGetStatusResponse + 5, // 2: SpaceX.API.Device.DishGetStatusResponse.device_state:type_name -> SpaceX.API.Device.DeviceState + 6, // 3: SpaceX.API.Device.DishGetStatusResponse.obstruction_stats:type_name -> SpaceX.API.Device.DishObstructionStats + 7, // 4: SpaceX.API.Device.DishGetStatusResponse.alerts:type_name -> SpaceX.API.Device.DishAlerts + 0, // 5: SpaceX.API.Device.DishGetStatusResponse.state:type_name -> SpaceX.API.Device.DishState + 1, // 6: SpaceX.API.Device.Device.Handle:input_type -> SpaceX.API.Device.Request + 3, // 7: SpaceX.API.Device.Device.Handle:output_type -> SpaceX.API.Device.Response + 7, // [7:8] is the sub-list for method output_type + 6, // [6:7] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_internal_starlink_pb_starlink_proto_init() } +func file_internal_starlink_pb_starlink_proto_init() { + if File_internal_starlink_pb_starlink_proto != nil { + return + } + file_internal_starlink_pb_starlink_proto_msgTypes[0].OneofWrappers = []any{ + (*Request_GetStatus)(nil), + } + file_internal_starlink_pb_starlink_proto_msgTypes[2].OneofWrappers = []any{ + (*Response_DishGetStatus)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_internal_starlink_pb_starlink_proto_rawDesc), len(file_internal_starlink_pb_starlink_proto_rawDesc)), + NumEnums: 1, + NumMessages: 7, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_internal_starlink_pb_starlink_proto_goTypes, + DependencyIndexes: file_internal_starlink_pb_starlink_proto_depIdxs, + EnumInfos: file_internal_starlink_pb_starlink_proto_enumTypes, + MessageInfos: file_internal_starlink_pb_starlink_proto_msgTypes, + }.Build() + File_internal_starlink_pb_starlink_proto = out.File + file_internal_starlink_pb_starlink_proto_goTypes = nil + file_internal_starlink_pb_starlink_proto_depIdxs = nil +} diff --git a/internal/starlink/pb/starlink.proto b/internal/starlink/pb/starlink.proto new file mode 100644 index 0000000..ac63153 --- /dev/null +++ b/internal/starlink/pb/starlink.proto @@ -0,0 +1,74 @@ +// A minimal slice of the Starlink dish's local gRPC API: just the one +// unary RPC (SpaceX.API.Device.Device/Handle) and the get_status request +// and dish status response, reduced to the handful of fields rtnetmon +// shows. The proto package name and field numbers must match the dish +// exactly for the wire format to line up; every other field the dish sends +// is ignored as an unknown field. +// +// Field numbers, message names and the DishState enum are transcribed from +// the community-maintained pre-generated bindings at +// github.com/starlink-community/starlink-grpc-go, commit +// 2e89f3d7e3092adecbcc04e9e003e61ed5f7c6a3 (get_status = 1004, +// dish_get_status = 2004). They are not verified against a live dish here. +// +// Regenerate with: +// protoc --go_out=. --go_opt=paths=source_relative \ +// --go-grpc_out=. --go-grpc_opt=paths=source_relative \ +// internal/starlink/pb/starlink.proto + +syntax = "proto3"; + +package SpaceX.API.Device; + +option go_package = "git.eeqj.de/sneak/rtnetmon/internal/starlink/pb;starlinkpb"; + +service Device { + rpc Handle(Request) returns (Response); +} + +message Request { + oneof request { + GetStatusRequest get_status = 1004; + } +} + +message GetStatusRequest {} + +message Response { + oneof response { + DishGetStatusResponse dish_get_status = 2004; + } +} + +message DishGetStatusResponse { + DeviceState device_state = 2; + DishObstructionStats obstruction_stats = 1004; + DishAlerts alerts = 1005; + DishState state = 1006; + float pop_ping_drop_rate = 1003; + float downlink_throughput_bps = 1007; + float uplink_throughput_bps = 1008; + float pop_ping_latency_ms = 1009; +} + +message DeviceState { + uint64 uptime_s = 1; +} + +message DishObstructionStats { + float fraction_obstructed = 1; +} + +message DishAlerts { + bool motors_stuck = 1; + bool thermal_shutdown = 2; + bool thermal_throttle = 3; + bool unexpected_location = 4; +} + +enum DishState { + UNKNOWN = 0; + CONNECTED = 1; + SEARCHING = 2; + BOOTING = 3; +} diff --git a/internal/starlink/pb/starlink_grpc.pb.go b/internal/starlink/pb/starlink_grpc.pb.go new file mode 100644 index 0000000..c9614f3 --- /dev/null +++ b/internal/starlink/pb/starlink_grpc.pb.go @@ -0,0 +1,140 @@ +// A minimal slice of the Starlink dish's local gRPC API: just the one +// unary RPC (SpaceX.API.Device.Device/Handle) and the get_status request +// and dish status response, reduced to the handful of fields rtnetmon +// shows. The proto package name and field numbers must match the dish +// exactly for the wire format to line up; every other field the dish sends +// is ignored as an unknown field. +// +// Field numbers, message names and the DishState enum are transcribed from +// the community-maintained pre-generated bindings at +// github.com/starlink-community/starlink-grpc-go, commit +// 2e89f3d7e3092adecbcc04e9e003e61ed5f7c6a3 (get_status = 1004, +// dish_get_status = 2004). They are not verified against a live dish here. +// +// Regenerate with: +// protoc --go_out=. --go_opt=paths=source_relative \ +// --go-grpc_out=. --go-grpc_opt=paths=source_relative \ +// internal/starlink/pb/starlink.proto + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v7.36.2 +// source: internal/starlink/pb/starlink.proto + +package starlinkpb + +import ( + context "context" + + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + Device_Handle_FullMethodName = "/SpaceX.API.Device.Device/Handle" +) + +// DeviceClient is the client API for Device service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type DeviceClient interface { + Handle(ctx context.Context, in *Request, opts ...grpc.CallOption) (*Response, error) +} + +type deviceClient struct { + cc grpc.ClientConnInterface +} + +func NewDeviceClient(cc grpc.ClientConnInterface) DeviceClient { + return &deviceClient{cc} +} + +func (c *deviceClient) Handle(ctx context.Context, in *Request, opts ...grpc.CallOption) (*Response, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Response) + err := c.cc.Invoke(ctx, Device_Handle_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// DeviceServer is the server API for Device service. +// All implementations must embed UnimplementedDeviceServer +// for forward compatibility. +type DeviceServer interface { + Handle(context.Context, *Request) (*Response, error) + mustEmbedUnimplementedDeviceServer() +} + +// UnimplementedDeviceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedDeviceServer struct{} + +func (UnimplementedDeviceServer) Handle(context.Context, *Request) (*Response, error) { + return nil, status.Errorf(codes.Unimplemented, "method Handle not implemented") +} +func (UnimplementedDeviceServer) mustEmbedUnimplementedDeviceServer() {} +func (UnimplementedDeviceServer) testEmbeddedByValue() {} + +// UnsafeDeviceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to DeviceServer will +// result in compilation errors. +type UnsafeDeviceServer interface { + mustEmbedUnimplementedDeviceServer() +} + +func RegisterDeviceServer(s grpc.ServiceRegistrar, srv DeviceServer) { + // If the following call pancis, it indicates UnimplementedDeviceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&Device_ServiceDesc, srv) +} + +func _Device_Handle_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Request) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DeviceServer).Handle(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Device_Handle_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DeviceServer).Handle(ctx, req.(*Request)) + } + return interceptor(ctx, in, info, handler) +} + +// Device_ServiceDesc is the grpc.ServiceDesc for Device service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Device_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "SpaceX.API.Device.Device", + HandlerType: (*DeviceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Handle", + Handler: _Device_Handle_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "internal/starlink/pb/starlink.proto", +} diff --git a/internal/starlink/starlink.go b/internal/starlink/starlink.go new file mode 100644 index 0000000..b15e0a6 --- /dev/null +++ b/internal/starlink/starlink.go @@ -0,0 +1,81 @@ +// Package starlink detects a Starlink dish on the local network and reads a +// short status summary from the dish's local gRPC endpoint. Detection and +// the status fetch sit behind the Client interface, so the monitor is +// tested with a fake and needs no dish and no network. Render turns a +// Status into the two lines the UI draws and is a pure function. +package starlink + +import ( + "context" + "fmt" + "time" +) + +// Address is the dish's fixed local gRPC endpoint. Every dish answers here +// from behind the Starlink router, whatever the LAN's own addressing is, so +// this is not the default gateway (that is the router). +const Address = "192.168.100.1:9200" + +// StateConnected is the dish state string that means the link is up; any +// other state is treated as an alert condition by Render. +const StateConnected = "CONNECTED" + +// Units used when reducing the dish's raw values to display values. +const ( + percentScale = 100.0 + bitsPerMegabit = 1_000_000.0 + hoursPerDay = 24 + minutesPerHour = 60 +) + +// Status is the subset of the dish's get_status response that rtnetmon +// shows. Throughput is megabits per second; obstruction and drop are +// percentages on a 0..100 scale. +type Status struct { + State string + Uptime time.Duration + ObstructionPct float64 + Alerts int + PopPingMs float64 + PopPingDropPct float64 + DownlinkMbps float64 + UplinkMbps float64 +} + +// Client probes for a dish and reads its status. The real implementation +// speaks gRPC to Address over one bound interface; tests use a fake. +type Client interface { + // Probe reports whether a dish answers on Address over the bound + // interface. It is cheap: a TCP connect, no gRPC call. + Probe(ctx context.Context) bool + // Status fetches the dish's current status. + Status(ctx context.Context) (Status, error) +} + +// Render formats a Status as the two status lines drawn under the physical +// pane, and reports whether they warrant an alert (red) color: the dish is +// not connected, or an alert is active. +func Render(s Status) (string, string, bool) { + line1 := fmt.Sprintf( + "Starlink: %s uptime %s obstruction %.1f%% alerts %d", + s.State, formatUptime(s.Uptime), s.ObstructionPct, s.Alerts) + line2 := fmt.Sprintf( + " pop ping %.0fms / drop %.1f%% down %.1fMbps / up %.1fMbps", + s.PopPingMs, s.PopPingDropPct, s.DownlinkMbps, s.UplinkMbps) + alert := s.State != StateConnected || s.Alerts > 0 + + return line1, line2, alert +} + +// formatUptime renders a duration as whole days, hours and minutes. +func formatUptime(d time.Duration) string { + if d < 0 { + d = 0 + } + + days := int64(d / (hoursPerDay * time.Hour)) + hours := int64(d/time.Hour) % hoursPerDay + mins := int64(d/time.Minute) % minutesPerHour + + return fmt.Sprintf("%dd %dh %dm", days, hours, mins) +} diff --git a/internal/starlink/starlink_test.go b/internal/starlink/starlink_test.go new file mode 100644 index 0000000..f65b12a --- /dev/null +++ b/internal/starlink/starlink_test.go @@ -0,0 +1,79 @@ +package starlink_test + +import ( + "testing" + "time" + + "git.eeqj.de/sneak/rtnetmon/internal/starlink" +) + +func TestRender(t *testing.T) { + t.Parallel() + + connected := starlink.Status{ + State: "CONNECTED", + Uptime: 3*24*time.Hour + 4*time.Hour + 12*time.Minute, + ObstructionPct: 0.1, + Alerts: 0, + PopPingMs: 34, + PopPingDropPct: 0.0, + DownlinkMbps: 145.3, + UplinkMbps: 12.1, + } + + tests := []struct { + name string + status starlink.Status + wantLine1 string + wantLine2 string + wantAlert bool + }{ + { + name: "connected, no alert", + status: connected, + wantLine1: "Starlink: CONNECTED uptime 3d 4h 12m obstruction 0.1% alerts 0", + wantLine2: " pop ping 34ms / drop 0.0% down 145.3Mbps / up 12.1Mbps", + wantAlert: false, + }, + { + name: "searching is an alert", + status: starlink.Status{ + State: "SEARCHING", + Uptime: 0, + }, + wantLine1: "Starlink: SEARCHING uptime 0d 0h 0m obstruction 0.0% alerts 0", + wantLine2: " pop ping 0ms / drop 0.0% down 0.0Mbps / up 0.0Mbps", + wantAlert: true, + }, + { + name: "connected but alerts active", + status: starlink.Status{ + State: "CONNECTED", + Uptime: 90 * time.Minute, + Alerts: 2, + }, + wantLine1: "Starlink: CONNECTED uptime 0d 1h 30m obstruction 0.0% alerts 2", + wantLine2: " pop ping 0ms / drop 0.0% down 0.0Mbps / up 0.0Mbps", + wantAlert: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + line1, line2, alert := starlink.Render(tt.status) + if line1 != tt.wantLine1 { + t.Errorf("line1 = %q, want %q", line1, tt.wantLine1) + } + + if line2 != tt.wantLine2 { + t.Errorf("line2 = %q, want %q", line2, tt.wantLine2) + } + + if alert != tt.wantAlert { + t.Errorf("alert = %v, want %v", alert, tt.wantAlert) + } + }) + } +}