Files
rtnetmon/internal/starlink/client.go
T
sneak e3ef4f2f48
check / check (push) Failing after 1s
Add Starlink status lines for the physical gateway pane (closes #3)
When the non-VPN physical gateway is a Starlink dish, two lines are shown
under that pane: state, uptime, obstruction and alert count; then pop-ping
latency and drop rate with downlink/uplink throughput. They turn red when
the dish is not connected or an alert is active.

Detection is a TCP connect to the dish's fixed endpoint 192.168.100.1:9200,
bound to the physical interface the same way the latency probes bind. Until
a dish answers nothing is drawn and no status is fetched, so an absent dish
adds no noise. Status comes from the dish's local get_status gRPC call.

Detection and the fetch sit behind a small Client interface; the loop and
the pure Render function are tested with a fake, no dish and no network.

Model: opus-4-8
2026-09-21 22:51:45 +00:00

130 lines
3.7 KiB
Go

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
}