check / check (push) Failing after 1s
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
130 lines
3.7 KiB
Go
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
|
|
}
|