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 }